unshutter (v.): to open the shutters; to let the light in.
A stock-media storefront an AI agent can take from git clone to a running, verified store — media, database, payments — without a single human signup. Every platform in the stack provisions claim-later environments (npx @cloudinary/cloud, npx neon-new, stripe sandbox create), so the human touches move to where they belong: claiming something that already works. Paste the Agent quick start into your agent and watch.
Built on Next.js 16 + Payload CMS + Neon + Stripe + Vercel, with Cloudinary as the media layer. Sibling to kickoff-cards (Cloudinary + Supabase).
Three product shapes:
- Photos — browse watermarked, heavily compressed Previews; paying unshutters the image with a time-limited signed link to the full-size Original
- Videos — an AI-selected 5-second watermarked, low-res preview clip (Cloudinary
e_preview) plus six auto-extracted watermarked stills; purchase downloads the original video file - Photosets — a whole folder of images (50+) sold as one product behind a handful of curated watermarked previews, delivered as a single zip of Originals
The core idea: the paywall is enforced by the media layer itself, not by hiding URLs. Originals live in Cloudinary as restricted assets (type: private for images, type: authenticated for video) that the CDN refuses to deliver unsigned. There is no user database, no entitlement table, no "secret" link that leaks — Stripe's payment record is the entitlement, and Cloudinary's signature requirement is the lock.
The provisioning story has names: Cloudinary Claimable Clouds, Neon Claimable Postgres, Stripe sandboxes — and Payload rounds out the stack as a code-first CMS an agent can read end to end. The agent's run stops at a verified local site; claiming and deploying stay yours. Under the hood, unshutter is also a real-world Cloudinary integration with some depth to it — restricted assets, strict transformations, AI video previews, signed archives (see What it demonstrates) — and the repo itself was built AI-assisted against live services, shipping the guardrails that made that work (see Developing with AI assistance).
- Restricted assets as a paywall — image Originals upload as
type: private, video Originals astype: authenticated; Cloudinary will not deliver the original bytes without a signed URL, full stop. (Video usesauthenticatedbecause it requires a signature on every rendition, original and derived — the stronger guarantee for paywalled video, and one that depends on no strict-mode configuration at all; the app's URL builders supply the signatures) - Strict Transformations + named transformations — with strict mode ON, only the allowlisted
t_preview/t_ogtransformations of a private asset resolve publicly; nobody can request the un-watermarked variant - AI video previews with
e_preview— Cloudinary's deep-learning excerpt picker cuts a 5-second highlight reel per video; chained with the watermark +q_auto:low, it is the merchandising.e_previewcan't generate on the fly (HTTP 423), so it's eager-generated at upload witheager_async+ a signature-verified webhook that flips the catalog'spreviewReadyflag - Frame extraction as a filmstrip — six watermarked stills per video from
so_<N>ppercentage offsets, eager-generated alongside the preview; the first doubles as player poster, Stripe thumbnail, and OG image - Zip archives as a deliverable — a purchased photoset downloads as one signed, expiring
generate_archivezip of 50+ private Originals, assembled on the fly; memberpublic_ids encode source filenames so the zip unpacks with human-readable names - Signed, expiring downloads — purchase mints a ~10-minute
private_download_url(or archive URL) per item, re-minted fresh on every visit to the durable download page - Degradation as the product — Previews use
q_auto:low+ a watermark overlay + a size cap; the compression isn't a cost to minimize, it's the merchandising - AI tagging + captioning on ingest — photo uploads request
google_tagging+captioning; tags and caption (used as alt text) write back to the catalog automatically. On clouds without the add-ons registered, ingestion degrades gracefully: the upload retries untagged instead of failing. Videos and photosets are tagged manually by design (built-in video tagging is dialogue-only; per-file AI across a 50-image set buys nothing) - Generative social crops — per-photo OG images from one
t_ogtransformation (w_1200,h_630,c_auto,g_auto) f_autodelivery under strict mode — previews render throughnext-cloudinary'sCldImagewithstrictTransformations, emitting exactly the allowlistedt_preview/f_autochain so WebP/AVIF negotiation survives strict mode; Cloudinary owns all optimization- Stripe as the entitlement system —
/download?session_id=…verifiespayment_status === 'paid'live against the Stripe API; the webhook only records Orders for reporting and grants nothing - Payload as the embedded catalog — admin at
/adminin the same Next.js app; collection hooks upload each Original to Cloudinary on save and destroy it on delete (deleting a photoset cascade-deletes its members and their assets)
![]() |
![]() |
- One gallery mixing photos, videos, and photosets — always watermarked Previews, never an Original
- Video cards hover-play their muted, looping 5-second preview; video pages add a player and a six-frame watermarked filmstrip
- Photoset cards carry a photo-count badge; set pages show the curated watermarked previews (
isPreviewper member, picked in the admin) - Mixed cart across all three product kinds (client state, localStorage) with live header badge — prices always re-derived server-side
- Guest checkout via Stripe Checkout; no accounts anywhere
- Durable download page: bookmark it, revisit any time, get fresh signed URLs each render — per-file downloads for photos/videos, one zip per photoset
- Purchased items clear from the cart automatically after checkout
- Photographer admin: create/curate the catalog, set pricing, watch Orders arrive; bulk-ingest a photoset folder with one command (
pnpm payload run scripts/ingest-photoset.ts <folder> "<title>" <priceCents>) - Per-item OG/social images; AI-generated tags and alt text for photos, manual tags for videos and sets
| Framework | Next.js 16 (App Router, TypeScript) |
| CMS | Payload 3 (embedded, code-first) |
| Database | Neon (Postgres, via @payloadcms/db-postgres) |
| Payments | Stripe (hosted Checkout, test mode) |
| Media | Cloudinary (next-cloudinary in the storefront, Node SDK v2 server-side) |
| Deploy | Vercel |
The entire site — services included — can be stood up locally by an AI agent with no human intervention: no accounts, no dashboards, no hand-edited .env. Deploying is deliberately not part of it; that's yours to do once you've seen it working. Paste this into your agent:
Clone https://github.com/cloudinary-devs/unshutter and get the storefront running locally, end to end. Don't ask me for credentials — provision claimable environments. Follow the agent quick start in the README, plus AGENTS.md and docs/AGENT-PROVISIONING.md. Verify a full test purchase against the local site. Don't deploy anywhere. When you're done, give me the local URL and the claim links.
If you are the agent, this is your runbook:
- Clone + install —
git clone https://github.com/cloudinary-devs/unshutter && cd unshutter && pnpm install && cp .env.example .env - Provision — no credentials needed:
Fill the rest of
npx @cloudinary/cloud # → CLOUDINARY_URL in .env + claim URL (24h) npx neon-new --yes # → connection string (copy to DATABASE_URI) + claim URL (72h) stripe sandbox create # → sk_test_... for STRIPE_SECRET_KEY
.env:NEXT_PUBLIC_CLOUDINARY_CLOUD_NAMEfrom the provisioned cloud name,PAYLOAD_SECRETfromopenssl rand -hex 32,NEXT_PUBLIC_SERVER_URL=http://localhost:3000. Record every claim URL and expiry — they are the handover. - Configure Cloudinary via the Admin API — create the named transformations and allowlist entries listed in Required Cloudinary console setup (exact recipes: src/lib/cloudinary/README.md). Then verify, don't assume: request an un-allowlisted derived URL of a private asset and expect 401. If it resolves, Strict Transformations is off — the one console toggle you must flag to your human.
- Bring up —
pnpm payload migrate, thenstripe listen --forward-to localhost:3000/api/stripe/webhookin the background (write the printedwhsec_...toSTRIPE_WEBHOOK_SECRET), thenpnpm dev. - Seed + verify end to end — create the first admin user with a Payload Local API script, upload a photo, then drive the storefront: add to cart → Stripe Checkout with
4242 4242 4242 4242→/downloaddelivers the un-watermarked Original via signed URL, and the unsigned original URL 401s. - Stop at local. Hand over — report the local URL, the admin credentials you seeded, and every claim URL with its expiry (Cloudinary 24h, Neon 72h). Do not deploy — hosting is the human's step, and the Cloudinary cloud must be claimed before a deployed site can serve media to anyone but this machine. Point them at docs/AGENT-PROVISIONING.md, Phases 5–6, when they're ready.
Requirements: Node 20+, pnpm, the Stripe CLI. Accounts are optional — every service below can be provisioned claim-later (next section).
git clone https://github.com/cloudinary-devs/unshutter
cd unshutter
pnpm install
cp .env.example .env
# fill in .env — see the file's comments for each value
pnpm devIf you (or your agent) don't have credentials yet, provision throwaway-until-claimed environments — no signup, no email verification, no dashboard:
npx @cloudinary/cloud # Claimable Cloud → writes CLOUDINARY_URL to .env, prints a claim URL (24h)
npx neon-new --yes # Claimable Postgres → connection string in .env, claim URL (72h)
stripe sandbox create # anonymous Stripe sandbox with working test keysEverything works immediately. When you like what you've built, open the claim URLs to keep the Cloudinary environment (free account, assets and credentials intact) and the Neon database. Until it's claimed, a Claimable Cloud locks media delivery to your IP — fine for local dev, claim before sharing a deployed URL. Full agent walkthrough: docs/AGENT-PROVISIONING.md; Cloudinary's docs: Claimable Cloud provisioning.
Otherwise, use an existing free Cloudinary account, any Postgres database (Neon free tier works), and Stripe test keys.
Then in a second terminal:
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the printed whsec_... into .env as STRIPE_WEBHOOK_SECRETOpen http://localhost:3000/admin, create the first admin user, and upload a few Photos — each save ingests the Original into Cloudinary privately and pulls back AI tags + caption. The storefront is at /. Check out with test card 4242 4242 4242 4242.
Videos: upload through the same Media collection (add tags manually — the built-in video tagger only understands dialogue). The watermarked preview clip and stills generate asynchronously; in production a Cloudinary webhook flips the video live, in local dev tick its Preview Ready checkbox after a minute (Cloudinary can't POST to localhost).
Photosets: ingest a whole folder as one product —
pnpm payload run scripts/ingest-photoset.ts ./my-folder "Golden Hour Beach Set" 4900 "beach,sunset"Every file uploads as a private set member; the first four are flagged as public previews (re-pick in the admin via each member's Is Preview checkbox).
The security model depends on console configuration that code cannot create — do this before Previews will resolve:
- Strict Transformations: ON (Settings → Security). Without it, derived versions of private assets are publicly addressable and the watermark is bypassable.
- Named transformation
preview(allowed_for_strict: true): watermark overlay +q_auto:low+c_limit,w_1200. - Named transformation
og(allowed_for_strict: true):w_1200,h_630,c_auto,g_auto. - Allowlist the chained variants
t_preview/f_autoandt_og/f_auto(f_autocan't live inside a named transformation). - Named transformation
video_preview:e_preview:duration_5→c_limit,w_640→ the same watermark overlay →q_auto:low. - Named transformation
video_still:c_limit,w_800→ the same watermark overlay →q_auto:low.
Videos don't depend on strict mode: they upload as type: authenticated, so every video URL the app emits — original and derived — is signed. Photoset zips need no console setup.
Full details and rationale: src/lib/cloudinary/README.md.
The storefront is built to be rebranded from exactly two files:
src/site.config.ts— every brand-voiced string: site name, header wordmark (including which leading segment gets the accent color), the gallery tagline, and the<meta>description.src/styles/unshutter-tokens.css— the entire visual identity as CSS custom properties: palette, the three font roles (display serif / UI sans / technical mono), spacing, radii, shadows, motion. The storefront stylesheet references only these tokens, so swapping values here restyles every page consistently.
No component edits needed for a rebrand. The gallery lays itself out from your content: cards keep each photo's natural aspect ratio and the masonry wall balances columns automatically, so it looks intentional whatever you upload.
- Architecture ← start here: how Payload, Neon, Stripe, and Cloudinary divide the work
- Product spec & decisions
- Ubiquitous language — canonical vocabulary (Original, Preview, Unlock…)
- Cloudinary integration — console setup, URL builders, dynamic folders
- Verified platform facts — current-docs findings the implementation relies on
- Agent provisioning walkthrough — clone → claimable environments → live deploy, with every human touch called out
- Loose ends & stretch items
The stack was chosen so an AI coding agent can provision real services, work against them, and hand them over — instead of guessing against mocks or waiting on a human to fill in .env:
- Cloudinary — Claimable Clouds:
npx @cloudinary/cloudgives an agent a live environment with zero human steps; agent docs, MCP servers + skills, and llms.txt let it inspect assets, test transformation chains, and verify private-asset behavior empirically - Neon — Claimable Postgres (
npx neon-new): instant database, claim URL for handover; MCP server for schema inspection - Stripe — anonymous sandboxes (
stripe sandbox create) with working test keys; CLI for triggering and tailing test webhooks; agent toolkit for the API - Vercel — CLI/MCP for deployments, env vars, logs, once you decide to host it (deploying is outside the agent's unattended scope — see docs/AGENT-PROVISIONING.md, Phase 6)
- Payload — no dashboard state to reverse-engineer; the whole CMS is TypeScript config in the repo
The pattern across all four platforms is the same and it's the interesting part: provision anonymously, build, verify, then hand the human a claim link. The human touches move from before the work (signup forms, key copy-pasting) to after it (claiming something that already works). docs/AGENT-PROVISIONING.md walks the whole flow for this app.
AGENTS.md encodes the working agreements that kept generated code on the rails (where calls live, what's non-negotiable in the security model, how models were delegated), and docs/VERIFIED.md records the "verify against current docs before building" research — both worth reading if you build with agents on fast-moving APIs.
pnpm test:int # Vitest unit + integration tests (colocated in src/lib/)
pnpm lint # ESLint
pnpm build # TypeScript check + production buildThe end-to-end purchase flow (browse → cart → Stripe test checkout → webhook → signed download) is exercised manually per the test plan in the spec.
MIT


