API reference

5 static read endpoints. 12 live application endpoints. One Stripe webhook. Honest framing on stability.

What this page is. The Factory exposes two API surfaces. The static surface is just files Caddy serves at fixed paths. The application surface is a small Node service that powers the catalog's buyer journey (dossier unlocks, intent capture, ratings, Stripe webhook).

What this page is not. Not a third-party-developer API contract. There is no versioning, no SLA, no rate limits. Shapes can change when we change them. We will not break you on purpose, but we will not coordinate either.

The honest framing. Treat the static endpoints as "public artifacts" you can rely on. Treat the application endpoints as "the same routes the catalog uses internally" - you can hit them, they are running, they work today. The Stripe webhook is live and logs every event.

Quick index GET /factory/state.json GET /factory/adoptability.json GET /factory/admin/health.json GET /factory/healthz GET /factory/dossiers/<slug>/teaser.md GET /factory/api/dossier/<slug> POST /factory/api/mint-token POST /factory/api/stripe-webhook POST /factory/api/intent POST/GET /factory/api/rate POST /factory/api/feedback POST /factory/api/event POST/GET /factory/api/validation POST /factory/api/afterhours/<route>

Static read endpoints

Files Caddy serves from disk. No backend. Refreshed by cron. Safe to poll.

GET/factory/state.jsonpublic

Mission-control state snapshot. The numbers on the homepage live here first. Refreshed every 5 minutes by regen-state-json.py.

Auth: none
Format: JSON
Cadence: every 5 min
Size: ~4 KB
{
  "generated_at": "2026-05-14T06:15:01.496803",
  "products": {
    "total": 249,
    "tiers": { "showcase": 8, "solid": 238, "rough": 0, "sketch": 3 }
  },
  "assets": {
    "founders": 39, "brand_pitches": 33, "elevator_pitches": 223,
    "hero_ui_mocks": 10, "skeptic_memos": 232
  },
  "cost": { "total_usd": 171.78 },
  "director": {
    "last_tick": 1,
    "last_tick_ts": "2026-05-14T05:55:07-07:00",
    "last_decision": null
  }
}
What "total" counts. Live products on the catalog. Slightly higher than adoptability.json's total, which counts only products with a current Adoptability score.
GET/factory/adoptability.jsonpublic

Every scored product with its 10-axis Adoptability breakdown, composite score, tagline, archetype, and category. The rawest catalog feed we publish. Refreshed every 15 minutes by adoptability-score.py.

Auth: none
Format: JSON
Cadence: every 15 min
Size: ~290 KB
{
  "generated_at": "2026-05-14T13:15:01.993825Z",
  "total": 244,
  "wes_picks": ["bookkeeper-ai", "dispatch-ai", "nurture-ai", "..."],
  "products": [
    {
      "slug": "bookkeeper-ai",
      "name": "Bookkeeper AI",
      "tagline": "Your books, done by morning.",
      "archetype": "marketplace-or-tool",
      "category": "finance",
      "axes": {
        "pain_intensity": 8, "buyer_clarity": 10, "speed_to_mvp": 7,
        "distribution_difficulty_easy": 10, "market_saturation_open": 10,
        "implementation_upsell": 7, "...": "..."
      },
      "score": 80
    }
  ]
}
How to read the score. The 10 axes are documented at /factory/adoptability/. The operator-honest reader's manual (four reading modes, three commonly misread axes) is at /factory/playbooks/reading-adoptability/.
GET/factory/admin/health.jsonpublic

Live health-check across 73 endpoints. Each entry includes HTTP status, response time in ms, response size, and description. This is what /quality-report/ reads. Refreshed every 10 minutes.

Auth: none
Format: JSON
Cadence: every 10 min
Size: ~19 KB
{
  "generated_at": "2026-05-14T13:10:04.207726Z",
  "total": 73,
  "passing": 73,
  "failing": 0,
  "avg_response_ms": 26,
  "results": [
    {
      "name": "homepage",
      "method": "GET",
      "path": "/factory/",
      "expected": 200,
      "actual": 200,
      "size": 18960,
      "elapsed_ms": 213,
      "ok": true,
      "description": "Mission control homepage"
    }
  ]
}
Why a JSON health endpoint. So /quality-report/ does not have to take a screenshot's word for it. Hit this URL and failing is either zero or it is not.
GET/factory/healthzpublic

One-line text probe for monitoring. Format: OK last_tick_age=Nm. Updated every minute.

Auth: none
Format: text/plain
Cadence: every 1 min
Size: ~22 bytes
OK last_tick_age=23m
What last_tick_age means. Minutes since the Director loop completed its most recent tick. If this climbs above ~30 during work hours, something has stalled and the autonomous claim is at risk. We get paged at 60.
GET/factory/dossiers/<slug>/teaser.mdpublic

Public Markdown teaser for every product. Returns the one-line summary, Adoptability score, category, archetype, and a preview of what unlocks at $5. 244 teasers live today.

Auth: none
Format: text/markdown
Cadence: regenerated every 30 min
Count: 244
# Account-Based Sales AI - dossier teaser

> Close the accounts that matter. At scale.

**Adoptability score:** 76/100 · **Category:** finance · **Build type:** enterprise

This is the public teaser. The full dossier unlocks for $5 at
https://wishdeal.com/factory/unlock/account-based-sales-ai/.
Slug list. All 244 slugs are in adoptability.json. Teaser path is deterministic: /factory/dossiers/<slug>/teaser.md. Sample: bookkeeper-ai.

Application endpoints (factory-api)

A small Node service Caddy reverse-proxies under /factory/api/*. CORS-enabled. JSON in, JSON out unless noted.

GET/factory/api/dossier/<slug>?t=<token>&format=md|htmlHMAC

Returns the full Markdown dossier for a product. Token is an HMAC-signed JWT-like string bound to the slug, minted at unlock. Default response is text/markdown; pass format=html for a styled standalone page.

Auth: HMAC token in ?t=
Format: markdown (default) / html
Cache: private, max-age=300
Token TTL: 365 days (default)
$ curl "https://wishdeal.com/factory/api/dossier/bookkeeper-ai?t=<token>"
# Bookkeeper AI - Full dossier

## ICP
Two-bookkeeper firms billing $300k - $800k...
[full markdown body]

# Without a valid token:
$ curl "https://wishdeal.com/factory/api/dossier/bookkeeper-ai?t=invalid"
{"error":"access denied","reason":"malformed","message":"Unlock the dossier at
 https://wishdeal.com/factory/unlock/bookkeeper-ai/ to receive a valid token."}
# HTTP 403
Why HMAC instead of session auth. Buyers keep their unlock as a forever-link. HMAC tokens are stateless, portable, verifiable without a database lookup, and bound to a single slug.
POST/factory/api/mint-tokenadmin

Mints an HMAC token for a slug. Used by the Stripe webhook on successful payment. Also callable directly with the admin key for ops (issuing a token to a buyer who paid via a different rail).

Auth: X-Admin-Key: $FACTORY_ADMIN_KEY
Format: JSON
Returns: token + full unlock URL
$ curl -X POST https://wishdeal.com/factory/api/mint-token \
    -H "x-admin-key: $KEY" \
    -H "content-type: application/json" \
    -d '{"slug":"bookkeeper-ai","ttl_seconds":31536000}'
{
  "ok": true,
  "slug": "bookkeeper-ai",
  "token": "eyJ...",
  "url": "https://wishdeal.com/factory/api/dossier/bookkeeper-ai?t=eyJ...",
  "expires_in": 31536000
}
TTL default. 365 days. The intent is that the buyer keeps the URL indefinitely. We will not invalidate tokens unless we find an abuse pattern.
POST/factory/api/stripe-webhooklive

Receives Stripe events. Logs every payload to /home/ubuntu/factory/inbox/stripe-webhook.jsonl for ops review. When STRIPE_WEBHOOK_SECRET is set in env, validates the Stripe-Signature header (HMAC-SHA256 over t=...,v1=...) before processing. Without the secret it runs in "placeholder" mode (logs, no signature check, no auto-mint).

Auth: Stripe signature (when SECRET set)
Format: JSON in
Body limit: 256 KB
Logs: inbox/stripe-webhook.jsonl
# Placeholder mode response (secret not set):
{ "ok": true, "mode": "placeholder",
  "message": "Stripe webhook endpoint is up but not yet validating signatures.
              Set STRIPE_WEBHOOK_SECRET to enable." }

# Signature mismatch:
{ "error": "invalid signature" }  # HTTP 400

# Expected event types: checkout.session.completed -> mints dossier token,
# emails buyer the URL. Pipeline is wired in code; full delivery activation
# is a Wes-blocker (SMTP creds + final QA on email template).
Why this is up before email is wired. Logging payloads first lets us validate the Stripe contract against real events before activating delivery. The stripe-webhook.jsonl file is the audit trail.
POST/factory/api/intentlive

Captures buyer intent: which product, what tier (unlock / adopt / operator), email, optional note. Used by the "Talk to us" CTA on product pages and the operator-partnership page. Appended to inbox/intent-capture.jsonl.

Auth: none
Format: JSON in/out
Body limit: 16 KB
CORS: wide-open POST
$ curl -X POST https://wishdeal.com/factory/api/intent \
    -H "content-type: application/json" \
    -d '{"slug":"bookkeeper-ai","tier":"operator","email":"a@b.com",
         "note":"Interested in white-labeling for my accounting agency"}'
{"ok": true}

# Validations:
# - slug required, max 80 chars, [a-z0-9_-]
# - tier must be one of: unlock | adopt | operator
# - email must match basic regex
POSTGET/factory/api/ratelive

Records or reads Wes's product tier ratings. The wes_picks field in adoptability.json is derived from these. Tiers: pursue / curious / shelf / not-for-me.

Auth: none (rate-limited by IP at the reverse proxy)
Format: JSON in/out
# Read all ratings:
$ curl https://wishdeal.com/factory/api/rate
{ "bookkeeper-ai": {"tier":"pursue","note":"clear pain","ts":"..."}, "..." }

# Set a rating:
$ curl -X POST https://wishdeal.com/factory/api/rate \
    -H "content-type: application/json" \
    -d '{"slug":"bookkeeper-ai","tier":"pursue","note":"clear pain"}'
{"ok": true, "ratings_count": 47}
POST/factory/api/feedbacklive

Generic buyer feedback. Captures slug (optional), message, optional email. Stored at inbox/feedback.jsonl. Used by the "Found a problem?" footer on product pages.

Auth: none
Format: JSON in/out
POST/factory/api/eventlive

Lightweight analytics event. Captures event_name, slug, optional props object. Stored at inbox/event-capture.jsonl. Used by the catalog's own client-side instrumentation (page view, scroll depth, CTA click).

Auth: none
Format: JSON in/out
POSTGET/factory/api/validation/<slug?>live

Per-product validation tracker. Records events like "demo booked", "operator signed", "first paying customer". Used by the foreman ticks and the per-product validation page.

Auth: none on GET; POST is rate-limited
Format: JSON in/out
# Read validation for a slug:
GET /factory/api/validation/bookkeeper-ai
{ "slug":"bookkeeper-ai", "events":[...], "operators":[...] }

# Read everything:
GET /factory/api/validation
{ "generated_at":"...", "products":{ "bookkeeper-ai":{...}, ... } }

# Record an event:
POST /factory/api/validation/event
{ "slug":"bookkeeper-ai", "type":"demo_booked", "data":{...} }
POST/factory/api/afterhours/<demo|signup>product

Product-specific endpoints for the afterhours build. demo runs the live in-browser demo against a fixture; signup appends to builds/afterhours/data.jsonl. Treat these as product-internal - not stable across the catalog.

Auth: none
Format: JSON in/out
Scope: just the afterhours product
Pattern. Each catalog build can register its own routes under /factory/api/<product>/*. So far only afterhours has live routes. The convention is documented for the Director loop's reference.

Stability and versioning

What we will not break

What might change without notice

What we will not promise

Asking us for more

If you are building something that needs a Wishdeal Factory feed we have not exposed (per-archetype rollups, vertical-filtered slices, founder list with handles, etc.), we will most likely just publish it. Email wes@wishdeal.com with the shape you want. The catalog is meant to be read.