minshop

mcp
Security Audit
Fail
Health Pass
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 10 GitHub stars
Code Fail
  • network request — Outbound network request in mcp/src/index.ts
  • network request — Outbound network request in scripts/agent-demo.mjs
  • rm -rf — Recursive force deletion command in scripts/destroy-local.sh
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Full-stack ecommerce on Cloudflare Workers, D1, and R2 - with admin, Stripe, OpenNode, Lightning payments, and agent-ready APIs.

README.md

minshop

An open-source store an agent can pay for itself. Agents read the catalog and pay a Lightning invoice with no human in the loop; people check out the normal way with Stripe. Merchants can run it over MCP too.

It's a small, server-rendered store for Cloudflare Workers (D1 + R2) with a full admin, multiple payment rails, and an optional MCP server, within the free tier to start. No brand is baked in: store name and time zone are set during onboarding, so it clones cleanly as a template.

Live demo (agent API at /api/products, /api/checkout). Uses test payments; do not enter real personal information.

It's intentionally lightweight and cheap to start. The default deployment uses Cloudflare Workers, D1, and R2 and can fit within their free-plan allowances; payment providers and optional services have their own pricing.

Features

  • Storefront — server-rendered product list + product detail, near-zero client JS
  • Cart — cookie-based (no client JS), with both "Add to cart" and one-click "Buy now"
  • Search — FTS5 full-text (bm25, prefix match, typo-correct), no JS — or optional semantic search via Workers AI + Vectorize (see Search)
  • Categories — nested (arbitrary-depth tree), many-to-many with products; storefront category pages with breadcrumbs + sub-category drill-down (recursive descendant queries)
  • Admin — products, categories, orders, customers, fulfillment, CSV export, and runtime store settings
  • Payments — Stripe Checkout, self-hosted Bitcoin Lightning (phoenixd or LNbits), hosted OpenNode, or a built-in demo rail; configure enabled methods and the default in Admin (see Payments)
  • Shipping — configurable zones, flat rates, and free-over-threshold rules; supported checkout flows capture the address and shipping cost on the order
  • Discount codes — promo-code field enabled from Admin; codes are created/managed in the Stripe Dashboard, and the applied discount is captured onto the order
  • Tax — sales tax / VAT via Stripe Tax (off by default — activate Stripe Tax in the Dashboard first); computed from the customer address and captured onto the order
  • Order email — confirmation email behind an EmailProvider seam with two adapters: Resend (HTTPS API, works on the Workers free plan) or Cloudflare Email (binding, paid plan); configured in Admin and a safe no-op until a provider is ready
  • Images — uploaded to R2, served through the app (no public bucket required)
  • Swappable seams — payments and storage sit behind interfaces (see Architecture)

Stack

Piece Choice
Framework Astro (SSR) on Cloudflare Workers via @astrojs/cloudflare
Styling Tailwind CSS v4 (@tailwindcss/vite)
Data Cloudflare D1 (SQLite) — products, orders
Images Cloudflare R2 — zero egress
Payments Stripe Checkout, Bitcoin Lightning (phoenixd / LNbits), OpenNode, or demo

Quick start (local)

Requires Node ≥ 22.12 — use Node 22, the tested/supported release line.

npm install

# One shot: build, migrate + seed the local DB, and generate the two local
# secrets (SECRETS_KEK + AUTH_SECRET) into .dev.vars
npm run provision:local -- --seed

npm run dev                        # http://localhost:4321

Storefront is at /, admin at /admin — first visit lands on the setup wizard. There are no provider secrets to fill in by hand: payment/email/Turnstile keys are pasted in Admin → Settings and stored encrypted in D1 (see Settings). Granular scripts also exist (db:migrate, db:seed), npm run preview runs the built worker in prod-mode (admin gate active), and npm run destroy:local resets the local store.

Tests

npm test          # vitest run (unit tests for pure logic)
npm run test:watch
npm run test:d1   # fresh migrations + seed + built Worker against isolated D1

Covers the pure functions — slugify, the FTS search sanitizer + edit-distance, parseProductForm, image validation, cart counting, reservation target aggregation, the order-number scheme, the Access-JWT verifier, pagination clamping, and the whitelisted orderByClause sort builders (the SQL-injection boundary for sortable tables). npm run test:d1 adds clean-room D1 gates for reservation concurrency/release/settlement/legacy compatibility, then boots the production Worker against an isolated database and runs a demo checkout through paid-order settlement and confirmation. npm run verify runs both suites, full Astro diagnostics, the production build, and the MCP typecheck/deployment dry run.

Testing payments locally

Paste your sk_test_… key in Admin → Settings → Payments → Card (Stripe), then forward Stripe events to your dev server with the Stripe CLI:

stripe listen --forward-to localhost:4321/api/webhook
# paste the printed whsec_… into Settings → Payments → Card (Stripe) → webhook signing secret
stripe trigger checkout.session.completed

The order shows up in /admin (and in D1: npx wrangler d1 execute minshop-db --local --command "SELECT * FROM orders").

Deploy

One-click (Deploy to Cloudflare)

Deploy to Cloudflare

Forks the repo, provisions D1 (minshop-db) + R2 (minshop-images), applies migrations, and deploys the free-plan default. Then finish onboarding at /admin/setup (below). Cloudflare Images, semantic search, and Cloudflare Email are opt-in; their bindings are commented in wrangler.jsonc.

Fields the deploy form shows:

  • SECRETS_KEK, AUTH_SECRET (masked, required) — the only two values you must supply. Paste a fresh random value into each (they must differ): run openssl rand -base64 32 twice. The button can't generate them, and masked-field hints are hidden, so a leading label field named USE_A_LONG_RANDOM_STRING_FOR_SECRETS_KEK_AND_AUTH_SECRET carries the instruction above them; its value is unused.
  • Location hint — Cloudflare's own field for where to place the D1 database; pick the region nearest your shoppers.
  • No store name / time zone / search fields — those are runtime settings you configure in the setup wizard and Admin → Settings (stored in D1), not at deploy time. Defaults until then: My Shop / UTC / keyword (FTS) search.

CLI (one shot)

One shot — provisions a fresh, fully-independent instance (its own D1, R2, Vectorize index, Worker) and sets both Worker secrets:

npx wrangler login
npm run provision:cf my-store      # scripts/provision-cf.sh <slug>

Or manually:

npx wrangler login

# Provision real resources. The committed wrangler.jsonc declares D1/R2 by NAME
# with no ids (so a one-click / Workers Builds deploy auto-provisions them); for a
# manual deploy, add the printed database_id to the "DB" entry — or let
# `wrangler deploy` create it.
npx wrangler d1 create minshop-db
npx wrangler r2 bucket create minshop-images
npm run db:migrate:remote          # applies migrations/ to the production DB

# The ONLY two Worker secrets. Everything else (Stripe, OpenNode, Lightning,
# Resend, Turnstile keys + their config) is entered in Admin → Settings and
# stored encrypted in D1 under SECRETS_KEK.
openssl rand -base64 32 | npx wrangler secret put AUTH_SECRET   # signs sessions
openssl rand -base64 32 | npx wrangler secret put SECRETS_KEK   # encrypts the key vault

npm run deploy                     # astro build && wrangler deploy

Then open the site — it funnels to the setup wizard: set the admin password (required to finish; until then /admin/setup is open, so do it right away or front /admin with Access), then paste your payment keys in Settings → Payments. Finally point a production Stripe webhook at https://<your-host>/api/webhook/stripe (events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, and checkout.session.expired; async success fulfils delayed methods, while failure/expiry releases held inventory) and paste its whsec_… signing secret in the same card. Tear an instance down with npm run destroy:cf <slug>; reset its data in place with npm run reset:remote.

Admin auth

Both /admin and /api/admin/* are gated by src/middleware.ts. You authenticate one of two ways:

  • Setup-wizard password (built-in). On a fresh store the setup wizard at /admin/setup is reachable so you can create an admin password; it's stored as a PBKDF2 hash in D1 (there is no ADMIN_PASSWORD env var). The moment you save it the gate activates: /admin + /api/admin require sign-in at /admin/login (a form issuing an HMAC-signed, HttpOnly session cookie; /api/admin returns 401 instead of redirecting), and saving logs you in immediately. First-run is open — until a password exists, /admin/setup is publicly reachable, so set it promptly on a public deploy (or front /admin with Access, below).
    • Optional Turnstile on the login form: enable it in Admin → Settings → Bot protection (toggle + sitekey, with the secret stored in the encrypted vault) to add Cloudflare's bot challenge before the password is checked. The same toggle also gates the customer account sign-in. Cloudflare's documented always-pass test keys work for local trials.
  • Cloudflare Access (recommended for production). Edge SSO/MFA, no app passwords, and it protects first-run setup too. In the Zero Trust dashboard add a self-hosted application covering both paths your-host/admin and your-host/api/admin (the API is a separate path — gating only /admin leaves the mutation endpoints open), add an identity provider (One-time PIN is enough), and an Allow policy for your email. Set both CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD in wrangler.jsonc vars; they are required for Access mode so the middleware can verify every JWT signature (zero-dependency, Web Crypto — see src/features/auth/access.ts). With either variable present, requests fail closed unless both are set and a valid assertion is supplied—including direct *.workers.dev requests with no header. (Use Access or the wizard password; if a password is set, password login takes precedence.)

Forgot the admin password? The hash lives in your D1 database, so the store owner can always reset it — clearing it drops the store back into the open setup wizard:

npm run admin:reset:remote   # deployed store  (or: npm run admin:reset for local)

Then reload /admin/setup and set a new one. (Same effect as running DELETE FROM settings WHERE key='admin_password_hash'; in the Cloudflare dashboard → D1 console.) It requires Cloudflare account access, so it isn't a public reset — and if you run Cloudflare Access you're never locked out in the first place.

Local dev (astro dev) bypasses the gate so you're never locked out. Don't make /admin a "secret" path — that's security-through-obscurity; the gate is what protects it.

The Worker also applies native edge rate limits to anonymous login POSTs (10/minute per store, route, and connecting client) and checkout/invoice POSTs (20/minute). Webhooks and authenticated admin APIs are deliberately excluded; provider signatures and the admin gate protect those paths without disrupting legitimate retries or bulk administration. The limits are declared as AUTH_RATE_LIMITER and CHECKOUT_RATE_LIMITER bindings in the provisioning template; existing manually maintained Wrangler configs need the same ratelimits block.

Payments

Checkout and the webhook depend on a PaymentProvider port (src/features/payments). Every rail is configured in Admin → Settings → Payments — its keys go in the encrypted vault, its config (default rail, node URLs) in D1 settings; a rail's checkout button appears automatically once its key is set, and the always-on Demo checkout works with zero keys. The "default rail" selector picks which one is offered first:

Rail What it is Setup (all in Settings → Payments)
stripe (default) Hosted card checkout paste the secret key + webhook signing secret
lightning Bitcoin Lightning via a self-hosted node — minshop mints a BOLT11 invoice and renders its own /pay page (QR + lightning: link), then confirms settlement run phoenixd or LNbits (below); enter its URL + key
opennode Hosted Lightning checkout (custodial processor) — redirect + webhook, like Stripe paste the OpenNode API key

Two ports, nested: the outer PaymentProvider (stripe / lightning / opennode) and an inner LightningBackend (phoenixd / lnbits) shared by the self-rendered flow. Adding another node is one adapter file.

How settlement is trusted. Lightning webhooks are treated as an untrusted nudge — on receipt minshop re-polls the node for the payment (the authority), so a forged webhook can't fake a sale. The /pay page also settles on load by polling, so it works even with no public webhook (e.g. local dev). Orders stay "paid-only": unpaid invoices live in a pending_payments table, never in orders.

Shipping (Lightning). When shipping is enabled in Admin, the Lightning cart routes through minshop's own /checkout page — a server-rendered address + shipping-option step (zone-based rates from store.config.ts, see Shipping) — so the invoice total includes shipping and the address + email land on the order, same as Stripe. Programmatic checkout accepts the same address as a ship_to object. (Stripe keeps its own hosted address/shipping collection, unchanged.)

Limitations (Lightning). Tax and promo codes are still Stripe-Checkout features and are skipped on the Lightning path — to charge tax on a non-Stripe rail you'd compute it yourself (e.g. Stripe's Tax Calculation API) and add it to the total. No automatic refunds (Lightning can't reverse in place). Invoices are priced in sats from a BTC spot rate fetched at checkout (payments.lightning.rateUrl, default Coinbase — no key).

Running phoenixd (recommended self-hosted backend)

phoenixd is ACINQ's self-custodial headless daemon — no full Bitcoin node, no manual channel management (ACINQ is the LSP), mainnet (real sats).

  1. Run phoenixd on an always-on host (small VPS / Pi). First launch writes a seed to ~/.phoenix/ — back it up.
  2. Expose its API (127.0.0.1:9740) to the Worker over HTTPS — a Cloudflare Tunnel (cloudflared) is the cleanest, no open ports.
  3. In Settings → Payments → Lightning, pick the phoenixd node, enter its public URL, and paste the password — use the http-password-limited-access value (~/.phoenix/phoenix.conf): receive-only, can't spend.
  4. Point phoenixd's webhook-url at https://<your-host>/api/webhook/lightning.

LNbits instead: pick LNbits in the same card and enter its URL + the wallet's Invoice/read key. OpenNode: paste the API key in its card and set the default rail to OpenNode.

Architecture

Organized as feature folders (vertical slices) — each owns its data access, types, and components.

src/
  config.ts                  build-time defaults and feature toggles
  store.config.ts            per-store build-time overrides
  layouts/Layout.astro
  features/
    auth/       admin, customer sessions, Access, Turnstile
    products/   catalog data, forms, inventory, image handling
    orders/     order data, reservations, tracking
    payments/   provider port + Stripe, Lightning, OpenNode, demo adapters
    settings/   runtime D1 settings
    storage/    provider port + R2 adapter
  pages/
    index.astro               storefront
    product/[slug].astro
    category/[slug].astro
    admin/                    products, categories, orders, customers, settings
    images/[...key].ts        serves private R2 objects
    api/                      catalog, checkout, webhooks, admin mutations

Two ports-and-adapters seams keep vendor code at the edges:

  • PaymentProvider — checkout and webhook routes depend on the interface rather than a single provider. Adding another rail means implementing an adapter and wiring its settings and webhook route.
  • StorageProvider — the app stores and serves images by key; R2 is one adapter.

Deliberately no runtime plugin system — Workers bundle at build time, so composition is just imports + a factory.

Database migrations

Schema lives in migrations/ as numbered, additive SQL files (D1 tracks which have run, so deploys only apply new ones). To evolve the schema, add a new file — never edit an applied one:

npx wrangler d1 migrations create minshop-db add_product_sku   # writes migrations/0002_…
# edit it (ALTER TABLE / CREATE TABLE — additive, no destructive DROP)
npm run db:migrate            # local
npm run db:migrate:remote     # production

seed.sql is dev-only sample data (re-runnable), kept separate from schema.

Settings

Most settings are runtime — changed in /admin/settings, stored in a D1 settings table, applied instantly, no redeploy. Secret keys are stored in the same table AES-256-GCM-encrypted under the SECRETS_KEK Worker secret (write-only from the UI, never echoed back), which is what makes an instance fully configurable — and clonable — from the dashboard:

  • General — store name.
  • Features — cart/checkout, "Buy now", discounts, tax, customer accounts, shipping, and image optimization.
  • Payments — one card per rail (Stripe / Lightning / OpenNode / Demo): its on/off toggle, config (node URLs, default rail), and keys.
  • Email — on/off, provider (Resend / Cloudflare), from-address, key, send-test button.
  • Bot protection — Turnstile toggle + sitekey + secret.
  • Search — keyword (FTS5) vs semantic (Workers AI + Vectorize) + reindex.

A few data-coupled settings stay build-time (change + npm run deploy):

Setting Where Effect
Currency currency in src/store.config.ts One store-wide currency — prices are stored as integer minor-units bound to it, so it can't safely flip at runtime
Image width images.maxWidth in src/store.config.ts Maximum width used when image optimization is enabled in Admin
Order number orderNumber.{offset,step,randomStep} in src/store.config.ts Friendly customer-facing number derived from the internal id (e.g. #1000). step spaces them out; randomStep adds jitter to obscure the count (keep step > randomStep). The URL/security uses the random public_id, not this number
Favicon replace public/favicon.svg Browser tab icon

Change a value, then npm run deploy. Currency uses Intl.NumberFormat, so usd → $, eur → €, gbp → £, jpy → ¥ (decimals handled per-currency).

Optional Cloudflare Images optimization

The default deployment stores original uploads in R2 and serves them through the Worker. Astro uses its passthrough image service, so deploying minshop does not automatically provision an IMAGES binding.

To optimize new uploads:

  1. Uncomment "images": { "binding": "IMAGES" } in wrangler.jsonc (or add the same binding to a provisioned instance config).
  2. Run npm run deploy.
  3. Enable Optimize images on upload in Admin → Settings. Optionally override images.maxWidth in src/store.config.ts.

Cloudflare Images transformations are available on Free and Paid plans. The Free plan currently includes 5,000 unique transformations per month; when the binding is missing or a transformation fails, minshop stores the original upload instead. Existing R2 objects are not retroactively transformed.

Shipping and order email

  • Shipping — switch it on or off in Admin → Settings. Define destination zones in src/store.config.ts (provider-agnostic, see features/shipping/calculator). Each zone has countries (ISO alpha-2, or ['*'] catch-all, matched top-to-bottom), rates (label + amount), and freeOverCents (a $0 "Free shipping" option once the subtotal qualifies; null to disable). The same engine feeds Stripe Checkout's options and the Lightning checkout total. Stripe uses the country selected in the cart; Lightning prices the entered ship_to.country.
  • Email — configured in Admin → Settings → Email (on/off, provider, from-address; the key in the encrypted vault). Unconfigured it's a safe no-op — checkout still succeeds. A Send test email button verifies real delivery. Two providers:
    • Resend (default, works on the Workers free plan — a plain HTTPS call): get a free key at resend.com, paste it in Settings → Email, and set the from-address (a Resend-verified domain, or [email protected] to test to your own address).
    • Cloudflare (Workers Paid plan): onboard a sender domain, add the commented send_email binding from wrangler.jsonc, redeploy, then pick Cloudflare in Settings → Email with a from-address on that domain. The section flags whether the binding is wired.

Theming

Brand color, accent, font, and corner radius are design tokens in src/styles/theme.css (a Tailwind v4 @theme block) — rebrand a clone by editing a few values, no component changes:

@theme {
  --color-brand: #111827;        /* solid buttons, focus ring */
  --color-brand-hover: #374151;  /* its hover shade */
  --color-accent: #2563eb;       /* text links */
  --font-sans: ...;
}

These generate the bg-brand / border-brand / text-accent utilities used across the UI, so changing one value re-skins every button and link at once. There is no runtime templating or theme engine: the components are the template, and tokens are the knobs.

Gotchas

Collected while building (the kind of thing that costs an afternoon):

  • Node ≥ 22.12 required — use Node 22, the tested/supported release line.
  • Bindings via import { env } from 'cloudflare:workers'Astro.locals.runtime.env was removed in Astro v6.
  • No main / assets in wrangler.jsonc — the Cloudflare adapter supplies the worker entry itself; setting main breaks a clean build.
  • CSRF is on by default — Astro rejects cross-origin form POSTs (403). Real browsers send Origin and pass; only scripted requests need to set it.
  • Webhooks need constructEventAsync — the sync verifier uses Node crypto, which isn't available on Workers.
  • One local dev server at a time — two astro dev instances share the same .wrangler local-D1 state and race, surfacing as transient no such table errors. Use one server, or pass a separate persist dir.
  • wrangler d1 export fails with FTS5 virtual tables — D1 can't export a DB that has virtual tables. To back up: drop products_fts, export, then recreate it (re-run migration 0003).
  • FTS5 MATCH throws on raw input — special chars (-, ", :, *) cause fts5: syntax error. Sanitize to alphanumeric prefix tokens before querying (see features/products/search.ts).

Search

Product search sits behind a SearchProvider seam (features/search). Choose the active backend in Admin → Settings → Search (or use the SEARCH_PROVIDER build-time fallback):

  • fts (default) — SQLite FTS5 keyword search: $0, fully local, exact-match + typo-correction ("did you mean"). Nothing to set up.
  • vectorsemantic search via Workers AI embeddings + Vectorize: matches by meaning ("something to drink coffee from" → mug), and powers similarity. Needs the AI + Vectorize bindings, so:
# 1. Create the index (dimensions must match the model — bge-base = 768)
wrangler vectorize create minshop-products --dimensions=768 --metric=cosine
# 2. Uncomment the "ai" + "vectorize" bindings in wrangler.jsonc
# 3. Select semantic search in Admin → Settings → Search
# 4. Use the Reindex button there to backfill existing products

The same embeddings also power "you may also like" on product pages — semantic similarity (the product's nearest neighbours in Vectorize), falling back to category-based related when vector search is off. After enabling, products are embedded automatically on create/update (and removed on delete). If vector is selected but the bindings are missing, it falls back to FTS rather than breaking. FTS stays available either way — keep it for exact/SKU lookups. Cost: Workers AI is pay-per-inference (cheap embeddings, free daily allocation) and Vectorize is usage-billed beyond a free tier — so semantic search trades the strict $0 for meaning-based matching.

Accounts (passwordless customer login)

Optional customer accounts via magic-link sign-in — no passwords, so no hashing/reset/breach liability. Enable them in Admin → Settings; guest checkout is unaffected (login is only for order history). When on they need the AUTH_SECRET secret (signs the login token + session cookie) and email configured (to send the link).

How it works: /account/login emails a single-use, 15-min link → /account/verify sets a 30-day signed session cookie → /account lists the customer's orders (queried by their verified email — which orders already store, so there's barely any new schema). Reuses the Web-Crypto HMAC token primitive and the EmailProvider seam; swapping to OAuth = replacing features/auth/customer.ts.

# needs: the AUTH_SECRET worker secret (provision scripts set it automatically)
#        + email configured in Admin → Settings → Email
# enable: Admin → Settings → Customer accounts

In local dev the magic link is also logged to the server console, so you can test without email delivery.

MCP server (operate the store from an assistant)

mcp/ is a standalone Cloudflare Worker that exposes store operations as MCP tools, so compatible MCP clients can run the store conversationally — "what was revenue this week?", "mark order 1142 shipped", "create a product". It binds the same D1 as the storefront and reuses features/*/db.ts (no duplicated logic).

Why a separate Worker (own mcp/package.json, own node_modules): the Astro adapter owns the storefront Worker's entry, and the Agents SDK pulls in workerd/miniflare deps that perturb Astro's build if hoisted into the root tree. Keeping it a sibling Worker isolates both.

Tools: list_products, get_product, list_orders, get_order, order_stats, daily_totals (reads) + create_product, update_product, fulfill_order (writes).

Auth: a bearer token. Set MCP_TOKEN; clients send Authorization: Bearer <token>. Unset = the server returns 503 (fail-closed). (Cloudflare's Workers OAuth Provider is the upgrade for scoped/multi-user access.)

cd mcp && npm install && cd ..
cp mcp/.dev.vars.example mcp/.dev.vars      # set MCP_TOKEN
npm run mcp:dev                              # local, shares the storefront's local D1
npm run mcp:check                            # dry-run build
# deploy: wrangler secret put MCP_TOKEN --config mcp/wrangler.jsonc ; npm run mcp:deploy

In production both Workers bind the same D1 (by database_id), so the MCP server operates the real store automatically. Connect any MCP client to https://<your-mcp-host>/mcp (streamable HTTP) with the bearer header. Note: write tools (create/fulfill) change live data — guard the token like a password.

Agent API (catalog + checkout)

A small, public, machine-readable API so an AI agent can browse and buy without scraping HTML or driving a browser — the "agent shops the store" counterpart to the MCP server's "agent operates the store". Clean JSON, absolute URLs, open CORS.

Method & path Purpose
GET /api/products Active catalog. ?q= (uses the active search backend — semantic when on), ?limit= (1–100, default 24), ?offset=
GET /api/products/:slug One product as JSON (404 if missing/inactive)
GET /api/checkout Available payment methods and the current default
POST /api/checkout Programmatic checkout. Body { "items": [{ "slug", "quantity" }] }{ checkout_url, … }

Each product is self-describing, price in both major + minor units:

{ "slug": "merino-wool-beanie", "name": "Merino Wool Beanie",
  "price": { "amount": 32, "cents": 3200, "currency": "USD" },
  "in_stock": true, "categories": ["Apparel"],
  "image": "https://…/images/products/merino-wool-beanie.webp",
  "url": "https://…/product/merino-wool-beanie" }

POST /api/checkout recalculates price and stock from D1. Set method to one of the values returned by GET /api/checkout; omitting it uses the default. Stripe and OpenNode return flow: "redirect" with a hosted checkout_url. Lightning returns flow: "invoice", a browser/QR fallback in checkout_url, and a directly payable BOLT11 invoice:

{
  "method": "lightning",
  "items": [
    { "slug": "merino-wool-beanie", "quantity": 1, "variant_id": 12, "extras": [3] }
  ],
  "ship_to": {
    "email": "[email protected]",
    "name": "Example Buyer",
    "line1": "123 Main Street",
    "line2": null,
    "city": "Portland",
    "state": "OR",
    "postal": "97205",
    "country": "US"
  },
  "shipping_label": "Standard"
}

When shipping is enabled, ship_to is required for Lightning; shipping_label is optional and defaults to the first rate for ship_to.country. When shipping is off, omit both fields. A successful Lightning response includes:

{
  "flow": "invoice",
  "checkout_url": "https://shop.example/pay/…",
  "lightning": {
    "invoice": "lnbc…",
    "amount_sat": 12345,
    "payment_hash": "…",
    "expires_at": "2026-07-23T20:30:00.000Z"
  },
  "shipping_cents": 500,
  "total_cents": 3700
}

The order is recorded only after the existing settlement verifier confirms payment. The browser form checkout is unchanged; the JSON path triggers only on Content-Type: application/json.

Demo — an agent shops the store

node scripts/agent-demo.mjs https://<your-host> "warm hat" 40
# Search "warm hat" under 40: 3 in-stock candidate(s)
#   USD 32  Merino Wool Beanie  [merino-wool-beanie]   ← picked (most relevant in budget)
# → prints the full Stripe checkout URL

Or just ask any tool-using LLM:

"Using <host>/api/products, find a warm hat under $40, then POST /api/checkout with {items:[{slug,quantity}]} and give me the checkout URL."

Complete payment with the Stripe test card 4242 4242 4242 4242 (any future expiry/CVC/postal) — prod runs Stripe test keys, so nothing is charged; the order then appears in /admin → Orders.

Gotcha: the checkout URL's #-fragment carries the session token — copy/print it whole. A truncated link yields Stripe's "Something went wrong".

Cost

The default Worker, D1 database, and R2 bucket can run within Cloudflare's free-plan allowances for a small store. Usage above those allowances is billed under Cloudflare's current pricing. Payment processors, email providers, semantic search, and optional image transformations may have separate free allowances or fees, so review the services you enable before going live.

Reviews (0)

No results found