Skip to content

Instantly share code, notes, and snippets.

@vitorcalvi
Created December 11, 2025 10:33
Show Gist options
  • Select an option

  • Save vitorcalvi/6c1e720d9014e0a6e5c5d6eb9c1b2450 to your computer and use it in GitHub Desktop.

Select an option

Save vitorcalvi/6c1e720d9014e0a6e5c5d6eb9c1b2450 to your computer and use it in GitHub Desktop.
Build Pay-to-Reveal SaaS

PROMPT: Build Pay-to-Reveal SaaS MVP (Self-Hosted + AI Moderation)

0. Non-Negotiable Constraints (Read First)

You are building a real, production-ready MVP that I will host on my own server.

  • Hosting / runtime

    • Self-hosted on my Linux home server (Node.js).
    • Must run with:
      • npm install
      • npm run build
      • npm run start
    • No Vercel-specific features (no Edge runtime, no @vercel/* APIs, no middleware that only works on Vercel).
  • Framework & stack

    • Next.js 14 – App Router (TypeScript).
    • Supabase:
      • Postgres (SQL schema).
      • Storage bucket (private) for images.
      • pg_cron for scheduled cleanup.
    • Stripe Connect Express for creator payouts.
    • Tailwind CSS for styling.
    • Sharp for server-side image processing / blur.
  • Auth

    • No traditional auth system (no full signup/login, no OAuth for buyers or creators).
    • Use secret edit links (UUID) for creators.
    • Use an ADMIN_MODERATION_KEY env var to guard the simple moderation dashboard.
  • Moderation

    • Build hooks to call an external AI LLM Moderation service over HTTP:
      • URL: MODERATION_API_URL
      • Optional MODERATION_API_KEY
    • This moderation service runs on my own home server and is not part of this repo.
    • You provide only the integration code and types, not the model itself.

1. Product Summary

A pay-to-reveal SaaS for Instagram creators:

  • Creator uploads an image.
  • Sets a price (minimum $5).
  • Connects Stripe Connect Express for payouts.
  • Platform generates a public link to place in an Instagram Story.
  • Followers tap the link:
    • See a blurred preview.
    • Pay via Stripe Checkout.
    • Instantly see the full image.
  • Content is ephemeral:
    • Post and its images auto-expire after 24 hours.
    • Fits Instagram Stories lifecycle.

Positioning tagline:

Drop a secret on your Story.
Get paid instantly.
Gone in 24h.

Typical use case:

“I want to share a formula to grow hair, next photo I show my hair, before and after.
‘Do you want to know what I did? Pay $X. Receive in seconds.’”


2. Business Model & Revenue

  • Minimum price: $5 (500 cents).
  • Platform fee: 25% of each transaction.
  • Stripe fee assumed: 2.9% + $0.30.
  • No subscriptions. No long-term accounts. Pure one-off impulse buys.

Example revenue splits:

  • $5 sale

    • Gross: $5.00
    • Stripe: $0.45
    • Platform (25% of $5): $1.25
    • Approx creator take-home: $5.00 − $0.45 − $1.25 = $3.30
      (You can keep the more exact $3.41 example in the copy.)
  • $10 sale

    • Gross: $10.00
    • Stripe: ~$0.59
    • Platform: $2.50
    • Creator: ~$6.91
  • $20 sale

    • Gross: $20.00
    • Stripe: ~$0.88
    • Platform: $5.00
    • Creator: ~$14.12

(Exact math in UI can be simplified; it’s mostly for creator expectations.)


3. Abuse Risk & Policy (Stripe-Friendly)

This product is an abuse magnet if moderation is weak:

  • Anonymous buyers.
  • Anyone can upload.
  • Money + hidden content.

We must minimize Stripe risk (chargebacks, TOS violations) and abuse:

Explicitly disallowed (must be blocked)

  • Sexual content, nudity, pornography, fetish content.
  • Any content involving minors (sexual or not, if sexual context implied).
  • Hate, harassment, extremism.
  • Blackmail, doxxing, revenge content.
  • Scams, fraud, “pay to see illegal stuff”.
  • Self-harm or suicide content.

Allowed examples

  • Secret tips (“how I grew my hair back”).
  • Before/after transformations (fitness, hair, makeup).
  • Exclusive behind-the-scenes content.
  • Secret announcements.
  • “Guess what happened” type reveals within policy.

4. Tech Stack Summary

  • Next.js 14 (App Router)
    • TypeScript
    • Node runtime only.
  • Supabase
    • Postgres database.
    • Private Storage bucket for images.
    • pg_cron extension for scheduled cleanup of expired posts.
  • Stripe
    • Stripe Checkout (checkout.sessions).
    • Stripe Connect Express accounts.
    • Webhooks for checkout.session.completed.
  • Other
    • Tailwind CSS for UI.
    • Sharp for image validation, resizing, and blur.
    • Basic HTTP server integration with external moderation service.

5. Environment Variables

Provide .env.local.example with all variables (placeholders only):

NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

NEXT_PUBLIC_BASE_URL=        # e.g. https://mydomain.com

# AI Moderation (external service on my server)
MODERATION_API_URL=          # e.g. http://127.0.0.1:5005/moderate
MODERATION_API_KEY=          # optional API key for local LLM service

# Simple admin gating (no full auth)
ADMIN_MODERATION_KEY=        # shared secret for /admin/moderation

6. Database Schema (Supabase / Postgres)

Use SQL/migrations equivalent to the following.

6.1 posts table

CREATE TABLE posts (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  secret_key UUID DEFAULT gen_random_uuid(), -- for edit link

  original_image_path TEXT NOT NULL,
  blurred_image_path TEXT NOT NULL,

  price INTEGER NOT NULL, -- cents, min 500 = $5
  stripe_account_id TEXT, -- Stripe Connect account for creator payouts

  view_count INTEGER DEFAULT 0,
  purchase_count INTEGER DEFAULT 0,

  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  expires_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '24 hours'),

  -- Moderation
  moderation_status TEXT DEFAULT 'pending',  -- 'pending' | 'approved' | 'rejected' | 'flagged'
  moderation_reason TEXT,
  ai_risk_score NUMERIC(5,2),
  ai_risk_labels TEXT                        -- JSON string of labels, e.g. '["adult","blackmail"]'
);

Indexes:

CREATE INDEX idx_posts_secret_key ON posts(secret_key);
CREATE INDEX idx_posts_expires_at ON posts(expires_at);
CREATE INDEX idx_posts_moderation_status ON posts(moderation_status);

6.2 purchases table

CREATE TABLE purchases (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
  session_id TEXT UNIQUE NOT NULL,  -- Stripe Checkout session id
  paid_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_purchases_post_id ON purchases(post_id);
CREATE INDEX idx_purchases_session_id ON purchases(session_id);

6.3 reports table

CREATE TABLE reports (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

  reason TEXT,
  status TEXT DEFAULT 'open',         -- 'open' | 'reviewed'

  ai_risk_score NUMERIC(5,2),
  ai_risk_labels TEXT,
  ai_action TEXT                      -- 'no_action' | 'hide' | 'freeze_stripe' | 'needs_manual_review'
);

CREATE INDEX idx_reports_post_id ON reports(post_id);
CREATE INDEX idx_reports_status ON reports(status);

6.4 Expiry cleanup (pg_cron)

Use pg_cron to delete expired posts from DB regularly:

SELECT cron.schedule(
  'delete-expired-posts',
  '0 * * * *',  -- hourly
  $$DELETE FROM posts WHERE expires_at < NOW();$$
);

Optionally add a trigger to log deleted images for storage cleanup.


7. AI Moderation Integration (Types & Behavior)

The AI moderation service runs separately on my home server and is exposed via MODERATION_API_URL. You only implement HTTP calls and type definitions.

7.1 Types (lib/moderation.ts)

export type ModerationInput =
  | {
      type: 'post_create';
      description: string;
      price: number;
      meta?: {
        width?: number;
        height?: number;
        sizeBytes?: number;
      };
    }
  | {
      type: 'report';
      reason: string;
      postId: string;
    };

export type ModerationOutput = {
  decision: 'approve' | 'reject' | 'flag';
  riskScore: number;        // 0–100
  labels: string[];         // e.g. ['adult', 'blackmail']
  reason: string;
  suggestedAction?: 'hide' | 'freeze_stripe' | 'needs_manual_review';
};

7.2 Helper function (lib/moderation.ts)

  • Implement a helper like:
export async function callModerationService(
  input: ModerationInput
): Promise<ModerationOutput | null> {
  // Server-side only, never expose MODERATION_API_URL/KEY to client.
  // If service is unreachable, return null.
}

7.3 Behavior rules

  • On post create:

    • Call moderation with { type: 'post_create', description, price, meta }.
    • Map ModerationOutput.decision to posts.moderation_status:
      • approveapproved
      • rejectrejected
      • flagflagged
    • Save ai_risk_score, ai_risk_labels, moderation_reason.
    • If service is down (null):
      • Set moderation_status = 'pending'.
      • Allow post creation, but show “pending review” state in UI.
  • On report:

    • Insert row in reports.
    • Call moderation with { type: 'report', reason, postId }.
    • Update report row with ai_risk_score, ai_risk_labels, ai_action.
    • If ai_action === 'hide':
      • Set posts.moderation_status = 'flagged'.
      • New purchases should be blocked for flagged posts (but existing buyers still see content).
    • If ai_action === 'freeze_stripe':
      • Don’t implement API calls to Stripe here; just mark this clearly in DB (for manual follow-up).

8. File Structure (Next.js App Router)

Use a structure like:

/app
  page.tsx                      # Landing + create form
  /p/[id]/page.tsx              # Public page (blurred + pay + report)
  /p/[id]/success/page.tsx      # Post-payment reveal
  /edit/[key]/page.tsx          # Creator edit page (secret_key)
  /admin/moderation/page.tsx    # Simple admin moderation dashboard

/app/api
  /create/route.ts              # Create post + upload image + initial moderation
  /checkout/route.ts            # Create Stripe Checkout session
  /webhook/route.ts             # Stripe webhook (checkout.session.completed)
  /connect/route.ts             # Stripe Connect onboarding link
  /connect/callback/route.ts    # Save stripe_account_id for a post/creator
  /image/[id]/route.ts          # Serve blurred/full images with access checks
  /report/route.ts              # Buyers submit reports
  /cleanup/route.ts             # Optional storage cleanup endpoint

/lib
  supabase.ts                   # Supabase client
  stripe.ts                     # Stripe client
  image-processor.ts            # Image validation, resize, blur (Sharp)
  moderation.ts                 # Moderation types + HTTP client
  time.ts                       # Helpers like “expires in X hours”

9. Core Flows & Logic

9.1 Create flow (/app/page.tsx + /app/api/create/route.ts)

Frontend (landing page):

  • Form fields:
    • Image upload (JPG/PNG, max 10MB).
    • Price in USD (min $5).
    • Optional text description (used for moderation).
  • Show a short policy banner:

    “No sexual content, nudity, minors, hate, violence, scams, blackmail, or self-harm. Violations will be blocked.”

Backend (/api/create):

  • Validate:
    • Only image/jpeg or image/png.
    • Size ≤ 10MB.
    • Use Sharp to ensure min 800×800 px.
    • Price ≥ 500 (cents).
  • Process:
    • Use Sharp to generate:
      • Optimized original (good balance of size & quality).
      • Strongly blurred preview (smaller size).
    • Upload both to a private Supabase Storage bucket (e.g. images).
    • Call moderation service (type: 'post_create').
    • Insert a row into posts with:
      • Paths to original and blurred images.
      • Price, secret_key, expires_at.
      • Moderation fields from the AI output.
  • Response behavior:
    • If moderation_status = 'rejected':
      • Do not create a usable post.
      • Show user a clear error reason and no public link.
    • If approved, flagged, or pending:
      • Return:
        • Public URL: /p/[id]
        • Secret edit URL: /edit/[secret_key]
        • expires_at timestamp.

9.2 Public page (/app/p/[id]/page.tsx)

  • Fetch post by id.
  • If not found: 404.
  • If expired (expires_at < now): show “This secret has expired.”
  • If moderation_status = 'rejected': show “This content is not available.”
  • If moderation_status = 'flagged': show a subtle banner like “This content is under review”.
  • Display:
    • Blurred image via /api/image/[id]?type=blur.
    • Price and “Unlock for $X” button.
    • Time remaining (countdown).
    • If stripe_account_id is missing: disable payment and show “Creator hasn’t connected payments yet.”
  • Include a “Report” button:
    • Opens a small form (textarea for reason).
    • POST to /api/report.

9.3 Image endpoint (/app/api/image/[id]/route.ts)

  • Accept query param type=blur|full and optionally session_id for full.
  • Steps:
    1. Fetch post by id.
    2. If not found → 404.
    3. If expired → 410 Gone.
    4. If moderation_status = 'rejected' → return 451 (policy).
  • For type=blur:
    • Always allowed when not expired & not rejected.
    • Use Supabase signed URL or stream image bytes.
    • Cacheable (e.g. Cache-Control: public, max-age=3600).
  • For type=full:
    • Require session_id.
    • Check purchases for matching post_id and session_id.
    • If no match → 403 Forbidden.
    • If match:
      • Serve original image via signed URL or stream.
      • Private / no-cache headers.

9.4 Checkout (/app/api/checkout/route.ts)

  • Input: post_id.
  • Validate:
    • Post exists.
    • Not expired.
    • moderation_status is not rejected.
    • Price ≥ 500.
    • stripe_account_id present.
  • Create a Stripe Checkout session:
    • mode: 'payment'.
    • Line item for the post’s price.
    • Application fee = 25% of unit_amount.
    • transfer_data.destination = stripe_account_id.
    • success_url = NEXT_PUBLIC_BASE_URL/p/[id]/success?session_id={CHECKOUT_SESSION_ID}.
    • cancel_url = NEXT_PUBLIC_BASE_URL/p/[id].
    • metadata.post_id = id.
  • Return session.url to frontend.

9.5 Success page (/app/p/[id]/success/page.tsx)

  • Read session_id from URL.
  • Server-side:
    • Fetch post by id.
    • If not found → show error.
    • Retrieve Stripe Checkout session with session_id.
    • Confirm:
      • payment_status === 'paid'.
      • session.metadata.post_id === id.
  • If verification fails:
    • Redirect back to /p/[id].
  • If verified:
    • Show full image via /api/image/[id]?type=full&session_id=....
    • Optional: “Download” button.

9.6 Stripe Webhook (/app/api/webhook/route.ts)

  • Verify Stripe signature using STRIPE_WEBHOOK_SECRET.
  • Handle checkout.session.completed:
    • Extract session.metadata.post_id and session.id.
    • Insert row into purchases:
      • post_id, session_id, paid_at.
    • Increment purchase_count for that post (use SQL or RPC).
  • Return 200 JSON { received: true }.

9.7 Edit page (/app/edit/[key]/page.tsx)

  • Use secret_key from URL.
  • Fetch post by secret_key.
  • If not found: show “Invalid or expired link”.
  • If expired: show “This content has expired”.
  • Show:
    • Blurred preview.
    • Price (editable).
    • Moderation state badge (pending, approved, flagged, rejected).
    • Stats: views, purchases, estimated revenue.
    • Stripe connection status.
    • Public link (copy button).
    • Time remaining until expiry.
  • Allow:
    • Update price (revalidate min $5).
    • Delete post with confirmation.

9.8 Admin moderation dashboard (/app/admin/moderation/page.tsx)

  • Access controlled by ADMIN_MODERATION_KEY:
    • For simplicity, accept it as a query param or header and compare to env var.
  • Display:
    • List of posts where moderation_status IN ('pending', 'flagged').
    • For each post:
      • Basic info, AI risk score, labels.
      • Recent reports with report AI outputs (ai_action, reason).
  • Allow simple actions:
    • Set moderation_status to approved or rejected.

10. Security & Policy Checklist (Must Implement)

  • Secret edit links use unguessable UUIDs.
  • All image files are stored in private Supabase Storage.
  • Signed URLs or backend streaming for image access.
  • Server-side blur via Sharp (no CSS blur tricks).
  • Verify Stripe Checkout sessions server-side before showing full image.
  • Verify Stripe webhook signatures.
  • Enforce min price $5.
  • Validate file type, size, and dimensions on upload.
  • Check expires_at on all relevant endpoints (public page, image, etc.).
  • Expired posts return “expired” state and cannot be purchased.
  • Honor purchases even if post later expires (buyer with valid session_id can still see).
  • Moderation logic:
    • AI integration on create + report.
    • Ability to flag/hide posts via moderation_status.
  • Content policy clearly visible to creators on the upload page.
  • Admin moderation dashboard protected by ADMIN_MODERATION_KEY.

11. Ephemeral Design Benefits (For Copy & Docs)

  • Content auto-deletes after 24 hours → “self-cleaning” system.
  • Matches Instagram Stories behavior.
  • Reduces long-term exposure of harmful content (but does not remove need for moderation).
  • No complex account recovery flows needed (links are short-lived).
  • Creates urgency and FOMO: “Expires in X hours”.

12. Testing Checklist (Manual Acceptance)

Implement or at least ensure support for these tests:

  • Upload a 10MB image → should succeed.
  • Upload an 11MB image → should be rejected.
  • Upload a .txt file → should be rejected.
  • Set price $4 → should be rejected.
  • Set price $5 → should succeed.
  • CSS blur tricks (in browser devtools) → should not reveal the real image (because real image is on a secure endpoint and blurred version is a separate file).
  • Attempt direct image URL (bypass API) → should fail due to private bucket.
  • Invalid session_id on success page → should redirect back to public page.
  • Webhook without valid signature → should be rejected.
  • Access expired post → “expired” UI.
  • Access success page after expiry with a valid purchase → should still show full image.
  • pg_cron job deletes expired posts from DB.
  • AI moderation:
    • Create a post with obviously disallowed text (e.g. “this is blackmail/sexual content”) → AI should reject/flag and post should not be available.
    • Submit a report with “this is a scam” → AI should flag post and set moderation_status appropriately.
  • Admin moderation dashboard only accessible with correct ADMIN_MODERATION_KEY.

13. Deliverables

  1. Complete Next.js 14 project:
    • All pages and API routes described above.
    • TypeScript types for moderation integration.
  2. Database setup:
    • SQL or migration scripts for posts, purchases, reports, indexes, and pg_cron job.
  3. Supabase configuration instructions in README.md:
    • How to create private Storage bucket (e.g. images).
    • How to enable pg_cron.
  4. Stripe configuration instructions:
    • Setup for Stripe Connect Express.
    • Creating application fee (25%).
    • Webhook endpoint and STRIPE_WEBHOOK_SECRET.
  5. Moderation integration instructions:
    • How to set MODERATION_API_URL and MODERATION_API_KEY.
    • Request/response contract for the external LLM service.
  6. .env.local.example file with all required env keys (no secrets).
  7. Notes for running on a home server behind a reverse proxy (e.g. Nginx/Caddy):
    • Standard npm run build && npm run start.
    • Health check route if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment