Dr J's Binding Protocol — Document 10

Commerce Infrastructure

Universal commerce patterns for payment processing, form handling, product feeds, order management, and security across all site builds.

← Back to Build Hub

1. Payment Processing

Every site that sells products uses Stripe Checkout (hosted) as the payment gateway. This is a non-negotiable architectural decision. We never handle card data on our servers.

Why Stripe Checkout (Hosted)

Stripe Checkout redirects the customer to a Stripe-hosted payment page. The card number, CVV, and expiration never touch our server, our database, or our code. This matters for three reasons:

Hard rule: No card data ever touches our server. No custom payment forms. No JavaScript that reads card numbers. Stripe Checkout (hosted redirect) is the only approved payment integration.

API Key Management

Stripe uses two key pairs — one for testing, one for live transactions. Both must be stored as environment variables, never in source code.

Variable Environment Purpose
STRIPE_SECRET_KEY Server-side only Creates checkout sessions, verifies webhooks, manages orders. Starts with sk_test_ or sk_live_
STRIPE_PUBLIC_KEY Client-side (safe to expose) Initializes Stripe.js on the frontend. Starts with pk_test_ or pk_live_
STRIPE_WEBHOOK_SECRET Server-side only Verifies that incoming webhook events are genuinely from Stripe. Starts with whsec_
Never commit keys to source control. Store them in .env files (added to .gitignore). On production, the .env lives in the shared/ directory and persists across deployments. Test keys go in your DevHub .env, live keys go in production .env.

Checkout Flow

The full lifecycle from cart to confirmed order:

── Customer clicks "Checkout" ──

1. Cart Page (Browser)
Customer reviews items, clicks "Proceed to Checkout"
Frontend sends POST to our API with cart contents

2. Create Checkout Session (Our API)
Server calls Stripe API: stripe.checkout.sessions.create()
Passes: line_items, success_url, cancel_url, metadata
Stripe returns a session URL

3. Redirect to Stripe (Browser)
Customer is redirected to Stripe's hosted checkout page
Stripe handles: card entry, validation, 3D Secure, Apple/Google Pay

4. Webhook Callback (Stripe → Our API)
Stripe sends POST to our webhook endpoint
Event: checkout.session.completed
We verify the signature, then process the order

5. Order Confirmed
Customer redirected to success_url (our "Thank You" page)
Order record created in our database
Confirmation email sent to customer + notification to site owner

Webhook Signature Verification

Webhooks are the authoritative signal that payment succeeded. The success URL redirect is not trustworthy — a user could visit that URL directly. Always verify webhook signatures before processing orders.

// PHP webhook verification
$payload = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$secret = getenv('STRIPE_WEBHOOK_SECRET');

// Extract timestamp and signature from header
$elements = explode(',', $sig_header);
// Parse t= and v1= values
$timestamp = ...; // from t= element
$signature = ...; // from v1= element

// Compute expected signature
$signed_payload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signed_payload, $secret);

// Constant-time comparison prevents timing attacks
if (!hash_equals($expected, $signature)) {
http_response_code(400);
exit('Invalid signature');
}

// Reject stale events (older than 5 minutes)
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('Timestamp too old');
}

Required Webhook Events

Event When It Fires What We Do
checkout.session.completed Payment succeeded and session is complete Create order record, decrement inventory, send confirmation email
checkout.session.expired Customer abandoned checkout (session timed out after 24 hours) Log the abandonment, optionally send recovery email if customer email was collected
Idempotency: Always check if an order already exists for a given Stripe session ID before creating a new one. Stripe may retry webhooks if our endpoint was slow to respond. Duplicate order creation is a real-world problem — guard against it.

2. Form Handling

Every site has forms — contact forms, quote requests, product inquiries. All forms follow the same server-side handler pattern regardless of what the form collects.

The Handler Pipeline

Every form submission passes through four stages in exactly this order:

1. VALIDATE
Check required fields are present
Check email format (filter_var + FILTER_VALIDATE_EMAIL)
Check length limits (prevent abuse)
Verify CSRF token matches session
Check rate limit (per-IP, time-window)
Check honeypot field is empty

2. SANITIZE
htmlspecialchars() on all text inputs
Strip tags where HTML is not expected
Trim whitespace

3. STORE
Insert into database via prepared statement
Record: timestamp, IP, user agent, form data

4. NOTIFY
Send HTML email to site owner
Include all form fields in formatted template
On DevHub: send to MailHog (devhub-mailhog:1025)
On Production: use sendmail (Exim auto-DKIM-signs)

CSRF Token Generation

Every form must include a CSRF token to prevent cross-site request forgery. The token is generated server-side, embedded in the form as a hidden field, and verified on submission.

// Generate token (on form page load)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// Embed in form
<input type="hidden" name="csrf_token"
value="<?= $_SESSION['csrf_token'] ?>">

// Verify on submission (constant-time comparison)
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('Invalid CSRF token');
}
Use hash_equals(), not == or ===. String comparison operators are vulnerable to timing attacks. hash_equals() runs in constant time regardless of where strings differ.

Rate Limiting

Prevent form abuse by limiting submissions per IP address within a time window.

Honeypot Spam Prevention

A honeypot is a hidden form field that real users never see or fill in, but bots fill automatically. If the field has a value on submission, the request is spam.

<!-- Hidden from real users via CSS -->
<div style="position:absolute;left:-9999px;" aria-hidden="true">
<label for="website_url">Leave this empty</label>
<input type="text" name="website_url" id="website_url" tabindex="-1" autocomplete="off">
</div>

// Server-side check
if (!empty($_POST['website_url'])) {
// Bot detected — silently reject
http_response_code(200); // Return 200 so bot thinks it worked
exit;
}
Return HTTP 200 for honeypot rejections. If you return an error code, sophisticated bots learn to leave the field empty. A silent 200 wastes fewer cycles on retry loops.

Email Notification Template

Every form submission triggers an HTML email to the site owner. The email must be clear, scannable, and include all submitted data.

Input Validation Summary

Field Type Validation Sanitization
Name Required, 2–100 characters htmlspecialchars(), trim()
Email Required, filter_var(FILTER_VALIDATE_EMAIL) filter_var(FILTER_SANITIZE_EMAIL)
Phone Optional, 7–20 characters, digits/spaces/dashes/parens only Strip non-numeric for storage, keep formatted for display
Message / Textarea Required, 10–5000 characters htmlspecialchars(), trim(), preserve newlines
Select / Radio Value must be in allowed list (whitelist check) Cast to expected type
Never trust client-side validation alone. HTML5 required and pattern attributes improve UX but are trivially bypassed. Every validation check must be duplicated server-side. Client-side is for convenience; server-side is for security.

3. Product Data Feeds

Product data feeds allow your products to appear in Google Shopping, Facebook/Instagram shops, and other marketplaces. The feed is a structured file generated from your product database and served at a public URL.

Google Merchant Center Feed

Google Shopping uses an RSS 2.0 feed with the g: namespace for Google-specific fields. The feed must be served as XML at a stable URL.

Required Fields

Field Description Example
g:id Unique product identifier (your internal SKU) PC-HD4500-BLK
g:title Product name (max 150 chars) HD-4500 Custom Full Chassis - Black
g:description Product description (max 5000 chars, no HTML) Plain text description of the product
g:link Product page URL (canonical, absolute) https://patriotchassis.com/products/hd-4500
g:image_link Primary product image URL (min 100x100px) https://patriotchassis.com/images/hd4500-front.jpg
g:price Price with currency code 24999.00 USD
g:availability Stock status in_stock, out_of_stock, or preorder
g:condition Product condition new
g:brand Brand name Patriot Chassis

Feed Structure

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
<channel>
<title>Patriot Chassis Products</title>
<link>https://patriotchassis.com</link>
<description>Custom chassis and suspension components</description>
<item>
<g:id>PC-HD4500-BLK</g:id>
<g:title>HD-4500 Custom Full Chassis - Black</g:title>
<g:description>Fully boxed ladder frame...</g:description>
<g:link>https://patriotchassis.com/products/hd-4500</g:link>
<g:image_link>https://patriotchassis.com/images/hd4500-front.jpg</g:image_link>
<g:price>24999.00 USD</g:price>
<g:availability>in_stock</g:availability>
<g:condition>new</g:condition>
<g:brand>Patriot Chassis</g:brand>
<g:identifier_exists>false</g:identifier_exists>
</item>
</channel>
</rss>
Identifier strategy: Custom-fabricated products (chassis, suspension kits, one-off builds) have no GTIN, UPC, or MPN. Set <g:identifier_exists>false</g:identifier_exists> to tell Google not to expect them. This prevents feed disapprovals.

Facebook / Instagram Feed

Facebook Commerce Manager accepts a CSV file with these columns:

Column Required Notes
id Yes Same SKU as Google feed for consistency
title Yes Product name
description Yes Plain text, max 9999 chars
availability Yes in stock or out of stock (space, not underscore)
condition Yes new, refurbished, or used
price Yes 24999.00 USD
link Yes Product page URL
image_link Yes Product image URL (min 500x500px recommended)
brand Yes Brand name

Feed Requirements

Feed Refresh Schedule

Validation

4. Product Data Architecture

Product data flows through a three-tier architecture. Understanding this hierarchy is mandatory before writing any code that reads, writes, syncs, or deploys product data.

The Three Tiers

TierNameDatabasePurpose
1. Portal Product Pipeline Portal portal_products + portal_product_images + portal_product_specs + related tables Where product data is born and enriched. Full editorial workflow with 13 section tabs, image management, specs, videos, documentation, marketing, and manufacturing data. Multi-site: every product belongs to a site.
2. THR Hub The High Road Hub thr_products The central distribution database. Receives synced data from the Portal. Serves as the single source of truth for client-facing product data, manufacturing status, and channel distribution. Client portal reads directly from this table.
3. Site DB Per-site product tables e.g. pc_products, ths_products Each website has its own independent product table. Receives data via deploy from THR Hub. The site reads only from its own table. This isolation ensures any site can operate as a standalone platform if separated from the Hub in the future.

Data Flow Model

Portal (birth) THR Hub (distribution) Site DB (serving) portal_products ──── Sync ────▶ thr_products ──── Deploy ────▶ pc_products portal_product_images (images as JSON) ths_products portal_product_specs (specs as JSON) (each site's own table)
Product data is born in the Portal. No other location creates product data. The Portal is the editorial environment with full metadata, images, specs, and workflow status.
Sync pushes Portal → THR Hub. The Portal ↔ THR Sync mechanism compares 31+ fields and transfers data in either direction. Images from portal_product_images are compiled into a JSON array on thr_products.images. Sync is bidirectional for field corrections but the Portal is authoritative.
Deploy pushes THR Hub → Site DB. A deploy reads active products from thr_products for a given client, writes them into the destination site's product table, copies image files to the site directory, and regenerates products-data.js and feed files.
Each site is self-contained. A site reads only from its own product table and its own products-data.js. It never queries thr_products or portal_products at runtime. This means any site can be detached from the Hub and operate independently.
Images must travel with the data. When syncing Portal → THR Hub, image records from portal_product_images are serialized into the thr_products.images JSON column. When deploying THR Hub → Site, image files are copied from the source site directory to the destination site directory, preserving subdirectory structure.

Key Tables

TableTierPrefixNotes
portal_productsPortalportal_Canonical product record. site_id links to portal_sites.
portal_product_imagesPortalportal_Normalized image table with metadata (alt, title, role, dimensions, status).
portal_product_specsPortalportal_Structured specs (group, key, value, unit).
thr_productsTHR Hubthr_Hub distribution table. client_id links to thr_clients. Images and specs stored as JSON columns.
pc_productsSite DBpc_Patriot Chassis website product table.
ths_productsSite DBths_The High Road website product table (THR Web).

Site-to-Client Mapping

The portal_sites table links the Portal tier to the THR Hub tier. Key columns:

A site can be a source (has thr_client_id), a destination (has serves_via_thr=1 with infrastructure), or both.

What Gets Synced vs. Deployed

DataPortal → THR Hub (Sync)THR Hub → Site DB (Deploy)
31 product fieldsYes (SYNC_FIELD_MAP)Yes
ImagesYes (portal_product_images → JSON)Yes (JSON → site table + file copy)
SpecsNo (deploy only)Yes (JSON)
Image filesN/A (paths only)Yes (copied to site directory)
products-data.jsN/ARegenerated
Feed filesN/ARegenerated

Separation Principle

The THR Hub and each Site DB are designed to be separable. If a client outgrows the Hub or needs an independent platform, their site already has its own product table, its own image directory, its own products-data.js, and its own feed files. Detaching requires only removing the deploy step — the site continues to function with its local data.

This is why every site must have its own product table with its own prefix, rather than querying the Hub at runtime. The Hub is a distribution mechanism, not a runtime dependency.

Implementation Reference — Dual-Prefix Configuration

Every site that participates in the three-tier architecture uses two database prefixes within the same database. This is the most commonly misunderstood aspect of the system. Both prefix constants must be defined in the site’s .env file and loaded in api/config.php.

ConstantValuePurposeUsed By
DB_PREFIX thr_ THR Hub tables — administrative catalog, client management, sync tracking Admin APIs only (/api/admin/*)
SITE_PREFIX ths_ Site DB tables — public-facing product data served to visitors Website APIs (public-products.php, client-products.php, orders.php, generate-products-js.php)
Both sets of tables must exist in the same database. The Hub tables (thr_*) and the Site DB tables (ths_*) live side-by-side. If only the thr_ tables are created (e.g., by running only the Hub migration), the website will fail because it queries ths_products which does not exist.

Production .env Template

# --- Database --- DB_HOST=localhost DB_NAME=thehighroadmanufacturing DB_USER=high_road_user DB_PASS=CHANGE_ME # --- CRITICAL: Both prefixes required --- DB_PREFIX=thr_ # Hub tier — admin APIs read/write here SITE_PREFIX=ths_ # Site tier — website reads from here # --- Site --- SITE_NAME=The High Road Manufacturing SITE_URL=https://thehighroadmanufacturing.com SITE_EMAIL=info@thehighroadmanufacturing.com CORS_ORIGIN=https://thehighroadmanufacturing.com # --- Email (production uses sendmail) --- MAIL_METHOD=sendmail # --- Stripe --- STRIPE_MODE=live STRIPE_PUBLISHABLE_KEY=pk_live_... STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_...

How config.php Loads the Prefixes

// In api/config.php — these constants drive ALL table routing define('DB_PREFIX', getenv('DB_PREFIX') ?: 'thr_'); define('SITE_PREFIX', getenv('SITE_PREFIX') ?: 'ths_'); // Usage in APIs: $hp = DB_PREFIX; // thr_ — admin queries $sp = SITE_PREFIX; // ths_ — website queries // Website API (public-products.php): $db->query("SELECT * FROM {$sp}products WHERE public_listed = 1"); // Admin API (admin/products.php): $db->query("SELECT * FROM {$hp}products WHERE active = 1");

API Routing Rules — Which Table, Which Prefix

This table is the single reference for which API endpoints read from which database tier. Getting this wrong is the #1 cause of deployment failures.

API FileReads FromWrites ToPrefixContext
public-products.php ths_products SITE_PREFIX Public shop pages
client-products.php ths_products ths_products + backpush to thr_products SITE_PREFIX (read) + DB_PREFIX (backpush) Client portal
orders.php ths_products thr_orders SITE_PREFIX (products) + DB_PREFIX (orders) Checkout & order history
generate-products-js.php ths_products + variant tables Writes js/products-data.js file SITE_PREFIX JS data regeneration
admin/products.php thr_products thr_products DB_PREFIX Admin product management
admin/push.php thr_products ths_products DB_PREFIX → SITE_PREFIX Hub-to-Site deploy
admin/pull.php ths_products thr_products SITE_PREFIX → DB_PREFIX Site-to-Hub sync
Never read from thr_products in website-facing code. If a public page, client portal page, order flow, or products-data.js generator queries thr_products instead of ths_products, it is a bug. The Hub is for admin management only. The website serves from the Site DB.

Site DB Table Schema

These tables must exist on the production database alongside the thr_ Hub tables. They are the tables the live website reads from. If they are missing, the site will return empty product listings or database errors.

ths_products — Site Product Table

CREATE TABLE IF NOT EXISTS ths_products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, client_id INT UNSIGNED NOT NULL, parent_product_id INT UNSIGNED DEFAULT NULL, slug VARCHAR(200) NOT NULL UNIQUE, sku VARCHAR(100) DEFAULT NULL, name VARCHAR(300) NOT NULL, category_type ENUM('assembly','part_of_assembly','part') NOT NULL DEFAULT 'part', category VARCHAR(150) DEFAULT NULL, description TEXT DEFAULT NULL, short_description VARCHAR(500) DEFAULT NULL, features JSON DEFAULT NULL, images JSON DEFAULT NULL, specs JSON DEFAULT NULL, documents JSON DEFAULT NULL, price_cents INT DEFAULT NULL, compare_price_cents INT DEFAULT NULL, retail_price_cents INT DEFAULT NULL, price_type ENUM('fixed','contact','quoted') NOT NULL DEFAULT 'fixed', weight_lbs DECIMAL(10,2) DEFAULT NULL, dimensions VARCHAR(100) DEFAULT NULL, brand VARCHAR(150) DEFAULT NULL, gtin VARCHAR(50) DEFAULT NULL, mpn VARCHAR(100) DEFAULT NULL, availability ENUM('in_stock','out_of_stock','preorder','made_to_order') NOT NULL DEFAULT 'in_stock', inventory_count INT DEFAULT NULL, public_listed TINYINT(1) NOT NULL DEFAULT 0, featured TINYINT(1) NOT NULL DEFAULT 0, stripe_product_id VARCHAR(100) DEFAULT NULL, stripe_price_id VARCHAR(100) DEFAULT NULL, channels JSON DEFAULT NULL, sort_order INT NOT NULL DEFAULT 0, active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_client (client_id), KEY idx_public (public_listed, active), KEY idx_sku (sku), KEY idx_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- NOTE: No foreign keys to thr_ tables. The site tier is intentionally -- decoupled from the hub tier so it can operate independently.

ths_product_variant_groups — Variant Selectors

CREATE TABLE IF NOT EXISTS ths_product_variant_groups ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, product_id INT UNSIGNED NOT NULL, group_key VARCHAR(100) NOT NULL, -- e.g. "color", "size", "material" display_label VARCHAR(255) NOT NULL, -- e.g. "Choose Color" input_type ENUM('select','swatch','radio') NOT NULL DEFAULT 'select', is_required TINYINT(1) NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

ths_product_variant_options — Variant Values

CREATE TABLE IF NOT EXISTS ths_product_variant_options ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, group_id INT UNSIGNED NOT NULL, option_value VARCHAR(255) NOT NULL, -- e.g. "red", "blue" display_label VARCHAR(255) NOT NULL, -- e.g. "Red", "Blue" price_adjustment_cents INT DEFAULT 0, sku_suffix VARCHAR(50) DEFAULT NULL, is_available TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_group (group_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Deployment Implementation — How Data Moves

This section documents the concrete steps that move product data from the Hub tier to the Site tier. The architectural rules above are meaningless if the deployment is not executed correctly.

Step-by-Step: Hub → Site DB Deploy

  1. Run all migrations — Execute every .sql file in migrations/ in order. This creates both the thr_ Hub tables and the ths_ Site tables. If the site tables migration is missing, create one before proceeding.
  2. Verify both table sets exist — Query SHOW TABLES LIKE 'thr_%' and SHOW TABLES LIKE 'ths_%'. You must see tables in both result sets.
  3. Populate Hub tables — Insert or import product data into thr_products, thr_clients, and thr_settings. This can come from a SQL dump, admin UI, or Portal sync.
  4. Push data to Site tables — Use /api/admin/push.php?action=push-all&site_id=N (or equivalent script) to copy products from thr_products to ths_products. This maps fields, copies images, and records the sync.
  5. Sync variant data — Variant groups and options must be populated in ths_product_variant_groups and ths_product_variant_options. The push mechanism handles this, or use the Portal deploy.
  6. Regenerate products-data.js — Run php api/generate-products-js.php (or call the endpoint). This reads ths_products + variant tables and writes js/products-data.js. Without this file, the shop page shows no products.
  7. Verify — Hit /api/public-products.php?action=list. If it returns products, the Site DB is correctly populated. If it returns an empty array or error, ths_products is missing or empty.
Deployment order matters. Migrations → Hub data → Push to Site DB → Regenerate JS. Skipping the push step is the most common failure — it leaves ths_products empty while thr_products has all the data. The website will appear to have no products.

Backpush: Site DB → Hub

Certain website actions write to the Site DB first, then “backpush” the change to the Hub to keep both tiers synchronized. This happens in client-products.php when a client toggles public listing or distribution channels:

// 1. Write to Site DB (immediate — this is what the website reads) $db->prepare("UPDATE {$sp}products SET public_listed = ? WHERE id = ?") ->execute([$val, $id]); // 2. Backpush to Hub (keeps admin catalog in sync) $db->prepare("UPDATE {$hp}products SET public_listed = ? WHERE slug = ?") ->execute([$val, $slug]);

The backpush uses slug matching (not ID) because the product’s ID may differ between the Hub and Site tables. The slug is the stable cross-tier identifier.

Common Deployment Failures

SymptomCauseFix
Shop page shows no products ths_products table is empty or doesn’t exist Run site tables migration, then push data from thr_products
API returns “Table doesn’t exist” Only thr_ tables were created; ths_ migration was skipped Run the site tables migration (008-site-tables.sql)
Admin shows products but website doesn’t Data is in thr_products (Hub) but never pushed to ths_products (Site) Run the push step: push.php?action=push-all
Products show but no variant selectors ths_product_variant_groups and ths_product_variant_options are empty Sync variants from Portal or populate directly
Client toggles don’t persist after re-deploy Backpush to Hub failed; re-deploy overwrote site changes Verify backpush is working; check for slug mismatches

5. Admin Dashboard Patterns

Every commerce site needs an admin interface for managing orders, products, and submissions. The admin follows a consistent pattern across all builds.

Authentication

Admin authentication uses a simple shared token model. No user accounts, no session management, no password hashing complexity.

// Server-side token check (every admin endpoint)
$token = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
$valid = hash_equals($settings['admin_token'], $token);

if (!$valid) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
Why not full user accounts? These are single-owner small business sites. One admin, one token. Adding user accounts, password hashing, password resets, and session management is unnecessary complexity. If multi-user admin is needed later, it can be added as a Phase 2 enhancement.

UI Patterns

The admin dashboard uses the same dark theme as the Hub and Binding Protocol. Consistent styling reduces cognitive switching.

Stats Cards

Top of dashboard shows aggregate numbers at a glance:

Data Tables

Status Management

No session management complexity. The admin token lives in sessionStorage (cleared when the browser tab closes). No cookies, no server-side sessions, no refresh tokens. If the tab is closed, log in again. Simple and secure.

6. Order Management Lifecycle

Every order follows a defined status flow from creation to completion. The statuses, transitions, and rules are the same across all sites.

Status Flow

pendingpaidprocessingshippeddelivered
↓          ↓
cancelled   refunded
Status Meaning Transitions To
pending Checkout session created, awaiting payment paid, cancelled
paid Payment confirmed via webhook processing, cancelled, refunded
processing Order being prepared / built / assembled shipped, refunded
shipped Shipped with tracking number provided delivered
delivered Confirmed received by customer (terminal state)
cancelled Order cancelled before shipment (terminal state)
refunded Payment returned to customer (terminal state)

Order Numbers

Inventory Tracking

Decrement on payment, not on cart add. If you decrement when an item is added to a cart, abandoned carts will ghost-reduce your inventory. Decrement only when checkout.session.completed fires and payment is confirmed. If a product sells out between cart add and payment, the webhook handler should flag the order for manual review.

Shipping Tracking

Email Notifications

Trigger Recipient Content
Payment confirmed Customer + site owner Order confirmation with items, total, order number
Status changed to shipped Customer Shipping notification with tracking number and link
Status changed to cancelled Customer Cancellation notice
Status changed to refunded Customer Refund confirmation with expected timeline

7. Customer Accounts

Customer accounts add significant complexity — password storage, reset flows, session management, profile pages, and data privacy obligations. For most small-business e-commerce sites, this complexity is not justified at launch.

Phased Approach

MVP (Launch) — No Accounts

Guest checkout is the primary flow. 35% of online shoppers abandon carts when forced to create an account (Baymard Institute). Guest checkout removes this friction. Accounts are a convenience feature, not a requirement.

Phase 2 (Future) — Customer Accounts

Phase 3 (Future) — Dealer / Wholesale Tiers

Keep it simple. Launch with guest checkout and order lookup by email + order number. Do not build accounts until there is a concrete business reason to do so. Every feature you don't build is a feature you don't have to secure, maintain, or support.

9. Security Checklist

This checklist covers every security measure required before a commerce site goes live. Every item must pass. No exceptions.

Status Security Measure Why It Matters
CSRF tokens on all forms Prevents attackers from submitting forms on behalf of other users via malicious links or embedded pages
Parameterized SQL queries (PDO prepared statements) Prevents SQL injection — the #1 web vulnerability. Never concatenate user input into SQL strings.
Rate limiting on submissions and checkout Prevents brute-force attacks, form spam floods, and checkout abuse
Webhook signature verification Prevents fake webhook events that could create fraudulent orders or grant unauthorized access
No card data on server (Stripe Checkout handles it) Eliminates PCI scope. No card data = no card data breach = PCI SAQ-A (simplest compliance level)
Input sanitization (htmlspecialchars) Prevents XSS (cross-site scripting) — malicious JavaScript injected via form fields
Email validation (filter_var) Prevents malformed email addresses, header injection attacks, and bounce storms
HTTPS everywhere Encrypts all data in transit. Required by Stripe, expected by customers, rewarded by Google
No secrets in source code API keys, tokens, and passwords must live in .env files, never committed to git
Error messages don't leak internals Generic errors to users ("Something went wrong"), detailed errors to logs only. No stack traces, SQL errors, or file paths in responses.
Honeypot on public forms Catches automated bots without degrading UX for real users. No CAPTCHA needed.
Admin endpoints require auth token Every admin API route must verify the Bearer token before processing. No unprotected admin endpoints.
A single missing item is a launch blocker. Every check in this list addresses a real-world attack vector. SQL injection, XSS, CSRF, and webhook spoofing are not theoretical — they are automated and constant. There is no "we'll fix it later" for security.

Pre-Launch Verification

Before deploying to production, manually verify each item:

Submit every form with empty required fields. Verify server-side validation rejects the submission even when browser validation is bypassed (e.g., via curl or browser dev tools).
Submit a form with <script>alert('xss')</script> in every text field. Verify the output is escaped, not executed. Check the database too — stored XSS is worse than reflected.
Send a webhook request without a valid signature. Verify the endpoint returns 400 and does not create an order.
Call every admin API endpoint without the Authorization header. Verify each returns 401 Unauthorized.
Search the entire codebase for hardcoded keys. Run grep -r "sk_live\|sk_test\|whsec_" . — there should be zero matches in source files.
Trigger an intentional server error and check the response. The customer-facing response must be generic. The detailed error must only appear in server logs.