setup-guides

Lulu Print API setup (print-on-demand ordering)

Phase 1 of the print path lets a creator/admin order physical printed copies of a novel-mode season — Lulu renders + drop-ships them. This guide wires the Lulu Print API credentials. Until they're set, the order UI returns a clean "not configured" 503 and only the Phase-0 PDF export works.

Code: services/lulu.ts · routes/print-orders.ts · PrintOrderModal.tsx · migration 419_print_orders.sql.

1. Create a Lulu developer account

  1. Sign up at https://developers.lulu.com/ (separate from a normal Lulu account). Use the sandbox first — sandbox orders are validated + priced but never actually produced or shipped, so it's free to test end-to-end.
  2. In the developer dashboard, create an API client → you get a client key + client secret.
  3. Sandbox base URL: https://api.sandbox.lulu.com · Production: https://api.lulu.com.

2. Set the backend env vars (Railway)

VarValue
LULU_CLIENT_KEYAPI client key
LULU_CLIENT_SECRETAPI client secret
LULU_API_BASEhttps://api.sandbox.lulu.com (start here) → https://api.lulu.com when live
LULU_CONTACT_EMAILfallback contact email put on print jobs (e.g. ops@…)

isLuluConfigured() only checks for the key + secret; base + contact have sensible defaults (sandbox + the order's shipping email).

3. Apply the migration

Run 419_print_orders.sql against prod (tracks each order + its Lulu job id + live status).

4. Test in sandbox

  1. Open a novel-mode season → super-admin tools → 📦 Order print copies.
  2. Fill a shipping address + quantity → Get price (live Lulu cost).
  3. Place order → renders the interior + cover, uploads them to the public season-assets bucket, creates the Lulu print job, and shows the job id + status. Sandbox responses are flagged "will NOT be produced."

5. Before going to production — verify these

  • ⚠️ Spine width. spineWidthInches() uses Lulu's published 444 PPI for 60# white BW. Confirm against Lulu's sandbox cover-template tool for the exact pod_package_id (0600X0900BWSTDPB060UW444MXX) — a wrong spine is the #1 cause of cover rejection. Adjust the constant in lulu.ts if Lulu's template disagrees.
  • Payment / wallet. Production print jobs require a funded Lulu wallet or a credit line on the account, or they sit UNPAID. Configure billing in the Lulu dashboard before flipping LULU_API_BASE to production.
  • Interior preflight. Phase 0's interior currently prints a page number on the title/copyright pages; Lulu accepts it, but the Paged.js upgrade (front matter without numbers, running heads) is the polish pass.

Status tracking (Phase 1.x — shipped)

Order status stays fresh three ways, so you don't have to open each order:

  1. print_orders_sync cron — every 15min the backend polls every in-flight order's Lulu job and updates the row. Heartbeat on /dashboard/admin/system-health; kill switch on /dashboard/admin/crons. No-op when Lulu isn't configured.
  2. Lulu webhook (optional, near-real-time) — point a Lulu webhook subscription at POST https://<backend>/api/print-orders/webhook for the PRINT_JOB_STATUS_CHANGED topic. Set LULU_WEBHOOK_SECRET to the subscription's signing secret and the endpoint verifies the Lulu-HMAC-SHA256 signature; without the secret it accepts unverified (fine for sandbox — set it before trusting webhooks in production).
  3. Admin dashboard/dashboard/admin/print-orders lists every order with status + a manual ↻ Sync now button (forces POST /api/print-orders/sync).
VarValue
LULU_WEBHOOK_SECRET(optional) signing secret from the Lulu webhook subscription — enables HMAC verification

Reader storefront (Phase 2 — shipped)

Readers can buy a printed copy of a public novel from the reading page (/seasons/[id]/read → "📖 Order the paperback"). They pay a retail price (Lulu cost + margin) via Stripe Checkout; on payment the backend renders + places the drop-shipped Lulu order. Requires Stripe to be configured (the existing STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET).

Margin is env-tunable:

VarDefaultMeaning
PRINT_MARGIN_PERCENT35markup over Lulu's all-in cost
PRINT_MARGIN_MIN_CENTS500absolute floor ($5) so cheap books still clear

Retail = max(cost×(1+pct), cost+floor), rounded up to the next 50¢. Fulfillment runs in the existing Stripe webhook (routes/payment.ts, kind:'print_order' branch → fulfillReaderPrintOrder()), so add print_order to your Stripe webhook's enabled events is not neededcheckout.session.completed already fires. Migration 420_print_orders_reader.sql adds the reader/payment columns. Orders appear on the same /dashboard/admin/print-orders dashboard (kind = reader).

⚠️ If a payment succeeds but fulfillment fails (e.g. a Lulu outage), the order is marked paid_unfulfilled and a critical service_error is logged — you have the buyer's money, so retry or refund manually.

Not yet (future)

  • Guest (logged-out) checkout — today the buyer must be signed in.
  • Multiple trim sizes / hardcover / color interiors (only 6×9 BW paperback).
  • A cover designer (the cover is auto-generated in the EmberKiln teal lockup).
LULU PRINT API SETUP — Docs | HiveJournal