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
- 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.
- In the developer dashboard, create an API client → you get a client key + client secret.
- Sandbox base URL:
https://api.sandbox.lulu.com· Production:https://api.lulu.com.
2. Set the backend env vars (Railway)
| Var | Value |
|---|---|
LULU_CLIENT_KEY | API client key |
LULU_CLIENT_SECRET | API client secret |
LULU_API_BASE | https://api.sandbox.lulu.com (start here) → https://api.lulu.com when live |
LULU_CONTACT_EMAIL | fallback 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
against prod (tracks each order + its Lulu job id + live status).419_print_orders.sql
4. Test in sandbox
- Open a novel-mode season → super-admin tools → 📦 Order print copies.
- Fill a shipping address + quantity → Get price (live Lulu cost).
- Place order → renders the interior + cover, uploads them to the public
season-assetsbucket, 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 exactpod_package_id(0600X0900BWSTDPB060UW444MXX) — a wrong spine is the #1 cause of cover rejection. Adjust the constant inlulu.tsif 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 flippingLULU_API_BASEto 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:
print_orders_synccron — 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.- Lulu webhook (optional, near-real-time) — point a Lulu webhook
subscription at
POST https://<backend>/api/print-orders/webhookfor thePRINT_JOB_STATUS_CHANGEDtopic. SetLULU_WEBHOOK_SECRETto the subscription's signing secret and the endpoint verifies theLulu-HMAC-SHA256signature; without the secret it accepts unverified (fine for sandbox — set it before trusting webhooks in production). - Admin dashboard —
/dashboard/admin/print-orderslists every order with status + a manual ↻ Sync now button (forcesPOST /api/print-orders/sync).
| Var | Value |
|---|---|
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:
| Var | Default | Meaning |
|---|---|---|
PRINT_MARGIN_PERCENT | 35 | markup over Lulu's all-in cost |
PRINT_MARGIN_MIN_CENTS | 500 | absolute 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 needed — checkout.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).