You can build a Fiverr clone in under a day using Next.js and the Whop infrastructure. Follow along this guide to have a full step-by-step walkthrough of building your own platform.

Key takeaways

  • Developers can build a full Fiverr-style marketplace by combining Next.js and Supabase with Whop's infrastructure for payments, KYC, and chat.
  • Whop handles the hardest marketplace problems—seller payouts, identity verification, and real-time messaging—so you never touch money or build these systems from scratch.
  • The platform earns revenue automatically by configuring an application fee (in basis points) on every Whop checkout, taking a cut before crediting the seller's connected account.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Building a freelance marketplace where users browse gigs, hire sellers, and complete work end-to-end can be done with Next.js, Supabase, and the Whop infrastructure.

In this tutorial, we're going to build such a clone (which we'll call GigFlow). A marketplace where users sign up, become verified sellers, list tiered packages, and deliver work to buyers with payments, KYC, and seller payouts handled by Whop. Our platform is also going to take a configurable cut of every order through application fees.

You can preview the demo of our project here.

Project overview

Before we dive deep into coding, let's take a general look at our project:

  • Verified seller marketplace where any user can become a seller through Whop's connected account flow with embedded KYC
  • Tiered gig packages (basic, standard, premium) with optional add-on extras and per-package delivery times
  • Slide-out checkout that opens on the gig page so buyers never leave your domain
  • Order lifecycle with requirements, delivery, revision requests, acceptance, and reviews
  • Database-enforced trust where a Postgres trigger refuses to publish gigs from unverified sellers
  • Embedded chat between buyers and sellers powered by Whop OAuth (chat: and dms: scopes)
  • Seller payouts via Whop's embedded payouts UI. Bank linking, balance, and withdrawals all in your app

Tech stack

  • Next.js - Server Components, API routes, and Vercel deployment in one framework
  • React - Server Components for data fetching, Client Components for interactivity
  • Tailwind CSS - CSS-first configuration with @theme blocks
  • Supabase - Postgres with Row Level Security and Realtime, plus Supabase Auth for email/password sign-in
  • Whop OAuth - "Continue with Whop" sign-in that unlocks embedded chat and DMs
  • Whop for Platforms - Connected accounts for seller onboarding, embedded KYC, checkout configurations with application fees, and embedded payouts
  • TypeScript - Strict typing across the stack
  • Vercel - Deployment with automatic builds from GitHub

Pages

  • /login - Email/password sign-in plus "Continue with Whop" OAuth
  • /account - Authenticated buyer landing page
  • /sell/onboarding - Connected-company creation and embedded KYC verification
  • /sell/dashboard - Seller workspace: balance, payout methods, withdrawals
  • /sell/orders - Seller's list of incoming orders
  • /sell/orders/[id] - Seller-side order workspace (deliver, message buyer)
  • /orders/[id] - Buyer-side order workspace (submit requirements, request revisions, accept delivery, review)
  • /checkout/complete - Post-payment confirmation landing

API routes

  • /api/auth/whop/authorize
  • /api/auth/callback/whop
  • /api/sell/onboard
  • /api/sell/kyc/sync
  • /api/sell/payouts-token
  • /api/sell/withdraw
  • /api/checkout/create
  • /api/checkout/confirm
  • /api/orders/[id]/requirements
  • /api/orders/[id]/deliver
  • /api/orders/[id]/request-revision
  • /api/orders/[id]/accept-delivery
  • /api/orders/[id]/review
  • /api/webhooks/whop
  • /api/chat/token
  • /api/token

Payment flow

  1. Buyer clicks Purchase on a gig, picks a package and extras, and the app creates a Whop checkout configuration with a platform application fee
  2. Buyer pays through the Whop checkout embed inside the slide-out panel
  3. Whop fires a payment_succeeded webhook. The app reconciles the order and stores the payment idempotently
  4. Whop credits the seller's connected company with the payment minus your platform fee
  5. Seller links a payout method through the embedded payouts UI and withdraws through /api/sell/withdraw

Why we use Whop

While building this project, we're going to face three important problems to solve: payments and seller payouts, identity verification, and real-time messaging:

  • For payments and payouts, we're going to use the Whop Payments Network with connected accounts, application fees, and embedded payouts so we never touch the money directly.
  • For identity verification, we're going to use Whop's embedded KYC so the entire ID-and-selfie flow happens inside our app without redirecting the user to a third-party site.
  • For real-time messaging, we're going to use Whop's embedded chat components so buyers and sellers get a full DM experience inside our app instead of us building a messaging system from scratch. Whop OAuth is just the mechanism we use to authenticate users into those components.

What you need to start

Before starting this project, you need:

  • A Whop sandbox account (create at sandbox.whop.com)
  • A Supabase account
  • A Vercel account
  • Working familiarity with Next.js and React

Step 1: Setting up the project

Prerequisites

Before you begin, make sure you have the following:

If you're all ready to go, let's clone the repository first. Open your terminal and run the following commands to clone the repository and move into the project directory:

Terminal
git clone https://github.com/kash2k6/GigFlowFiverClone.git
cd GigFlowFiverClone

Then, install the dependencies using the command:

Terminal
npm install

The key packages this project depends on are:

Key packages
{
  "@supabase/ssr": "^0.9.0",
  "@supabase/supabase-js": "^2.99.1",
  "@whop/checkout": "^0.0.52",
  "@whop/embedded-components-react-js": "^0.0.13-beta.10",
  "@whop/embedded-components-vanilla-js": "^0.0.13-beta.10",
  "@whop/sdk": "^0.0.32",
  "next": "16.1.6",
  "react": "19.2.3",
  "tailwindcss": "^4"
}

Set up environment variables

Create a file named .env.local in the root of the project. This file is listed in .gitignore and will never be committed. Add the following variables:

.env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key

# Whop
WHOP_API_KEY=whop_sk_...
WHOP_PLATFORM_COMPANY_ID=biz_...
WHOP_WEBHOOK_SECRET=whsec_...
PLATFORM_FEE_BPS=1000

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Whop Environment
NEXT_PUBLIC_WHOP_ENVIRONMENT=sandbox

# Whop OAuth
WHOP_OAUTH_CLIENT_ID=your_oauth_client_id
WHOP_OAUTH_CLIENT_SECRET=your_oauth_client_secret
NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback/whop

Here is what each variable does and where to find it:

VariablePurposeWhere to find it
NEXT_PUBLIC_SUPABASE_URLYour Supabase project URLSupabase dashboard → Project Settings → API
NEXT_PUBLIC_SUPABASE_ANON_KEYPublic browser key for SupabaseSupabase dashboard → Project Settings → API
SUPABASE_SERVICE_ROLE_KEYAdmin key that bypasses RLSSupabase dashboard → Project Settings → API
WHOP_API_KEYServer-side Whop API keyWhop dashboard → Developer → API Keys
WHOP_PLATFORM_COMPANY_IDYour platform's parent company ID (biz_...)Get it from the URL of your company dashboard
WHOP_WEBHOOK_SECRETUsed to verify webhook signaturesWhop dashboard → Developer → Webhooks
PLATFORM_FEE_BPSYour take rate in basis points (1000 = 10%)Set this yourself
NEXT_PUBLIC_APP_URLAbsolute base URL for redirectsYour domain, or http://localhost:3000 locally
NEXT_PUBLIC_WHOP_ENVIRONMENTsandbox for testing, production for liveSet this yourself
WHOP_OAUTH_CLIENT_IDOAuth app client IDWhop dashboard → Developer → Apps → OAuth
WHOP_OAUTH_CLIENT_SECRETOAuth app client secretWhop dashboard → Developer → Apps → OAuth
NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URIOAuth callback URLMust match what you registered in Whop
Setting NEXT_PUBLIC_WHOP_ENVIRONMENT to sandbox points the Whop SDK and embedded components at Whop's test environment.

You can use test card numbers and simulate payments without moving real money. Switch to production when you are ready to go live.

Validate your environment variables

After setting up the environment variables, let's first create environment variable validation so that if there's an error with any of the variables, we can clearly spot it instead of looking for cryptic issues in the logs. Create src/lib/env.ts:

env.ts
const required = [
  'NEXT_PUBLIC_SUPABASE_URL',
  'NEXT_PUBLIC_SUPABASE_ANON_KEY',
  'SUPABASE_SERVICE_ROLE_KEY',
  'WHOP_API_KEY',
  'WHOP_PLATFORM_COMPANY_ID',
  'PLATFORM_FEE_BPS',
  'NEXT_PUBLIC_APP_URL',
] as const;

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

export const env = {
  supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
  supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
  whopApiKey: process.env.WHOP_API_KEY!,
  whopPlatformCompanyId: process.env.WHOP_PLATFORM_COMPANY_ID!,
  platformFeeBps: parseInt(process.env.PLATFORM_FEE_BPS!, 10),
  appUrl: process.env.NEXT_PUBLIC_APP_URL!,
  whopEnvironment: (process.env.NEXT_PUBLIC_WHOP_ENVIRONMENT ?? 'sandbox') as 'sandbox' | 'production',
  whopOauthClientId: process.env.WHOP_OAUTH_CLIENT_ID,
  whopOauthClientSecret: process.env.WHOP_OAUTH_CLIENT_SECRET,
  whopWebhookSecret: process.env.WHOP_WEBHOOK_SECRET,
};

Vercel configuration

When you deploy to Vercel, preview deployments get their own URLs (like my-app-git-branch-username.vercel.app). If you hard-code redirect URLs using VERCEL_URL, those redirects will break in production because Vercel preview URLs require authentication to access.

The solution is a utility that always uses your canonical production URL when running on Vercel. Create src/lib/app-url.ts:

app-url.ts
/** Your canonical production URL. Update this when you deploy. */
const PRODUCTION_APP_URL = 'https://your-app.vercel.app';

/**
 * Returns the correct base URL for redirects and links.
 * Priority: NEXT_PUBLIC_APP_URL → canonical production URL (on Vercel) → localhost
 */
export function getAppBaseUrl(): string {
  if (process.env.NEXT_PUBLIC_APP_URL?.trim()) {
    return process.env.NEXT_PUBLIC_APP_URL.trim();
  }
  if (typeof process.env.VERCEL_URL === 'string') {
    return PRODUCTION_APP_URL;
  }
  return 'http://localhost:3000';
}

Checkpoint 1

At this point you should have:

  • Repository cloned and dependencies installed
  • .env.local file created with all required variables
  • src/lib/env.ts created for environment validation
  • src/lib/app-url.ts created for safe redirect URL handling
  • NEXT_PUBLIC_WHOP_ENVIRONMENT=sandbox set for local development

Step 2: Database schema and the KYC gate

Our project uses Supabase as its database. The schema is defined through SQL migration files that you apply in order. This section covers the most important parts of that schema and explains the design decisions behind them.

Running the migrations

First, install the Supabase CLI and link your project using the commands below in order:

Terminal
npm install -g supabase
supabase login
supabase link --project-ref your-project-ref

Then apply all migrations at once:

Terminal
supabase db push

This creates all the tables, indexes, triggers, and Row Level Security policies your marketplace needs.

Understanding the core tables

Here is a summary of every table in the schema and what it is responsible for:

TablePurposeKey columns
profilesUser identity and public profileuser_id, email, username, display_name, role
seller_accountsSeller state and Whop connectionuser_id, whop_company_id, kyc_status, payout_enabled
categoriesMarketplace categoriesslug, name, is_active
gigsGig listingsseller_user_id, title, slug, status, search_vector
gig_packagesTiered pricing per giggig_id, tier, price_cents, delivery_days, revisions_included
gig_extrasOptional add-ons per giggig_id, title, price_cents, max_quantity
ordersPurchased work contractsgig_id, seller_user_id, buyer_user_id, status
order_requirementsBuyer's project brieforder_id, answers, attachments, submitted_at
order_deliveriesSeller's delivery submissionsorder_id, message, items
order_messagesIn-order messaging (fallback)order_id, sender_user_id, body
reviewsPost-completion ratingsorder_id, gig_id, rating, body
whop_checkout_configsCheckout config trackingwhop_checkout_config_id, order_id, application_fee_cents
whop_paymentsPayment records from Whopwhop_payment_id, order_id, status
webhook_eventsIdempotent webhook inboxwebhook_id, type, payload

The status enums

The schema uses PostgreSQL enums to enforce valid status values. These are defined in the initial migration file at supabase/migrations/20250311000000_initial_schema.sql:

20250311000000_initial_schema.sql
-- User roles
create type public.user_role as enum ('buyer', 'seller', 'admin');

-- KYC verification status
create type public.kyc_status as enum ('unstarted', 'pending', 'verified', 'failed');

-- Gig lifecycle status
create type public.gig_status as enum ('draft', 'review', 'published', 'paused', 'rejected');

-- Order lifecycle status
create type public.order_status as enum (
  'awaiting_requirements', 'in_progress', 'delivered',
  'revision_requested', 'completed', 'cancel_requested',
  'cancelled', 'disputed', 'refunded'
);

-- Package tiers
create type public.package_tier as enum ('basic', 'standard', 'premium');

Using enums instead of plain text columns means the database will reject any invalid status value at the constraint level. You cannot accidentally set an order's status to "compelted" (typo). The database will throw an error immediately.

Auto-creating user profiles

When a new user signs up through Supabase Auth, a trigger automatically creates their profile record. This means you never have to manually create a profile after signup. It happens atomically as part of the auth transaction.

This trigger is defined in supabase/migrations/20250311000000_initial_schema.sql:

20250311000000_initial_schema.sql
create or replace function public.handle_new_user()
returns trigger language plpgsql
security definer set search_path = public as $$
begin
  insert into public.profiles (user_id, email, display_name)
  values (
    new.id,
    new.email,
    coalesce(
      new.raw_user_meta_data->>'full_name',
      new.raw_user_meta_data->>'name',
      split_part(new.email, '@', 1)
    )
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

Full-text search on gigs

The gigs table includes a generated tsvector column so buyers can search by keyword without a separate search service. The column is defined in supabase/migrations/20250311000000_initial_schema.sql:

20250311000000_initial_schema.sql
create table if not exists public.gigs (
  id uuid primary key default gen_random_uuid(),
  seller_user_id uuid not null references public.profiles(user_id) on delete cascade,
  category_id uuid references public.categories(id),
  slug citext not null unique,
  title text not null,
  description text not null,
  faq jsonb not null default '[]'::jsonb,
  status public.gig_status not null default 'draft',
  search_vector tsvector generated always as (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'B')
  ) stored,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index if not exists gigs_search_gin on public.gigs using gin (search_vector);
create index if not exists gigs_status_category_idx on public.gigs (status, category_id);

The setweight calls give the title ('A') higher relevance than the description ('B') in search results. The GIN index makes full-text queries fast even with thousands of gigs.

The bulletproof KYC trigger

This is the most important piece of the entire schema. A seller cannot publish a gig until they have completed identity verification. Most tutorials enforce this in the UI. They hide the "Publish" button or show an error message. But UI-level checks can be bypassed by calling the API directly.

GigFlow enforces this rule in the database using a PostgreSQL trigger, defined in supabase/migrations/20250311000000_initial_schema.sql. Even if someone calls your API directly with a crafted request, the database will reject the operation:

20250311000000_initial_schema.sql
create or replace function public.enforce_kyc_before_gig_publish()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
  kyc public.kyc_status;
begin
  if (TG_OP = 'INSERT' or TG_OP = 'UPDATE') then
    if new.status = 'published' then
      -- Look up the seller's KYC status
      select s.kyc_status into kyc
      from public.seller_accounts s
      where s.user_id = new.seller_user_id;
      -- Reject if not verified
      if kyc is null or kyc != 'verified' then
        raise exception 'Seller must complete KYC before publishing';
      end if;
    end if;
  end if;
  return new;
end;
$$;

-- Trigger: fires before any INSERT or status UPDATE on the gigs table
create trigger trg_enforce_kyc_before_gig_publish
before insert or update of status
on public.gigs
for each row
execute function public.enforce_kyc_before_gig_publish();

This trigger fires before every insert and every status update on the gigs table. If the seller's kyc_status is anything other than 'verified', the operation is rejected with an exception.

Checkpoint 2

At this point you should have:

  • Supabase CLI installed and project linked
  • All migrations applied with supabase db push
  • Confirmed that the trg_enforce_kyc_before_gig_publish trigger exists in your database

Step 3: Setting up Supabase clients

GigFlow uses two different Supabase clients: one for regular user requests that respects Row Level Security, and one admin client for server-side operations that need to read or write data across multiple users.

Create src/lib/supabase/server.ts with both clients:

server.ts
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

/**
 * Admin client — bypasses Row Level Security.
 * Use only in server-side API routes where you need to read or write
 * data that belongs to another user (e.g., creating notifications,
 * inserting orders on behalf of a buyer).
 */
export function createAdminClient() {
  return createSupabaseClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
}

/**
 * Regular client — respects Row Level Security.
 * Use for all user-facing requests. This client reads the user's
 * session from cookies and enforces RLS policies automatically.
 */
export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore errors from Server Components — cookies can only
            // be set in Server Actions and Route Handlers.
          }
        },
      },
    }
  );
}

Next, create src/lib/supabase/middleware.ts to handle session refresh logic:

middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Refresh the session on every request so it does not expire mid-session.
  // Do not remove this line — it is required for SSR auth to work correctly.
  await supabase.auth.getUser();

  return supabaseResponse;
}

Finally, create src/middleware.ts to run the session refresh on every request:

middleware.ts
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    // Run on all routes except static files and images
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Checkpoint 3

At this point you should have:

  • src/lib/supabase/server.ts created with both admin and regular clients
  • src/lib/supabase/middleware.ts created with the session refresh logic
  • src/middleware.ts created and running on all routes

Step 4: Authentication and the case for Whop OAuth

GigFlow supports two ways to sign in: email and password through Supabase Auth, and "Continue with Whop" via Whop OAuth. Both work. But Whop OAuth is the better choice for most platforms, and it is worth understanding why before you decide which to implement.

When a user authenticates through Whop OAuth, your app receives their Whop identity, including their whop_user_id and a refresh token. This is what enables embedded real-time chat later.

Without it, you can still build a messaging system using Supabase Realtime, but you will be building it yourself. With Whop OAuth, you get Whop's entire chat infrastructure for free.

Email and password login

The login page calls Supabase Auth directly. When signing up, it passes a signup_role metadata field that the handle_new_user() trigger uses to set the user's initial role.

Create src/app/login/page.tsx:

page.tsx
'use client';

import { useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');
  const [signupRole, setSignupRole] = useState<'buyer' | 'seller'>('buyer');
  const [isSignUp, setIsSignUp] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);

    if (isSignUp) {
      const { data, error } = await supabase.auth.signUp({
        email,
        password,
        options: {
          data: {
            full_name: name,
            signup_role: signupRole,
          },
        },
      });

      if (error) {
        setError(error.message);
        return;
      }

      if (data.session) {
        window.location.href = signupRole === 'seller' ? '/sell/onboarding' : '/account';
      }
    } else {
      const { error } = await supabase.auth.signInWithPassword({ email, password });

      if (error) {
        setError(error.message);
        return;
      }

      window.location.href = '/account';
    }
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-sm mx-auto mt-16 space-y-4">
      {isSignUp && (
        <input
          type="text"
          placeholder="Your name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="w-full border rounded px-3 py-2"
        />
      )}
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
        className="w-full border rounded px-3 py-2"
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
        className="w-full border rounded px-3 py-2"
      />
      {isSignUp && (
        <select
          value={signupRole}
          onChange={(e) => setSignupRole(e.target.value as 'buyer' | 'seller')}
          className="w-full border rounded px-3 py-2"
        >
          <option value="buyer">I want to hire</option>
          <option value="seller">I want to sell services</option>
        </select>
      )}
      {error && <p className="text-red-500 text-sm">{error}</p>}
      <button type="submit" className="w-full bg-black text-white rounded py-2">
        {isSignUp ? 'Create account' : 'Sign in'}
      </button>
      <button type="button" onClick={() => setIsSignUp(!isSignUp)} className="text-sm underline">
        {isSignUp ? 'Already have an account? Sign in' : 'No account? Sign up'}
      </button>
    </form>
  );
}

The Whop OAuth flow uses PKCE, which is the current best practice for OAuth in web apps. It prevents authorization code interception attacks by generating a one-time code challenge that is verified on the server.

Notice the scopes in the authorization request below. The chat:\* and dms:\* scopes are what unlock embedded chat later. If you skip them here, you cannot use Whop's embedded messaging without asking users to re-authenticate.

Create src/app/api/auth/whop/authorize/route.ts:

route.ts
import { NextResponse } from 'next/server';
import { randomBytes, createHash } from 'crypto';

function randomString(bytes: number): string {
  return randomBytes(bytes).toString('base64url');
}

async function sha256(input: string): Promise<string> {
  const hash = createHash('sha256').update(input).digest();
  return Buffer.from(hash).toString('base64url');
}

export async function GET() {
  const clientId = process.env.WHOP_OAUTH_CLIENT_ID;
  const redirectUri = process.env.NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI;

  if (!clientId || !redirectUri) {
    return NextResponse.json(
      { error: 'Whop OAuth is not configured. Set WHOP_OAUTH_CLIENT_ID and NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI.' },
      { status: 500 }
    );
  }

  const pkce = {
    codeVerifier: randomString(32),
    state: randomString(16),
    nonce: randomString(16),
  };

  // These scopes unlock embedded chat and DMs.
  // Do not remove chat:* and dms:* — they are required for the messaging features.
  const scopes = [
    'openid',
    'profile',
    'email',
    'chat:message:create',
    'chat:read',
    'dms:read',
    'dms:message:manage',
    'dms:channel:manage',
  ].join(' ');

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: scopes,
    state: pkce.state,
    nonce: pkce.nonce,
    code_challenge: await sha256(pkce.codeVerifier),
    code_challenge_method: 'S256',
  });

  const authUrl = `https://api.whop.com/oauth/authorize?${params}`;
  const response = NextResponse.redirect(authUrl);

  // Store PKCE values in a secure, HTTP-only cookie for the callback to verify.
  response.cookies.set('whop_oauth_pkce', JSON.stringify(pkce), {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 15, // 15 minutes
    path: '/',
  });

  return response;
}

After the user approves the OAuth request, Whop redirects them to your callback URL with an authorization code. Create src/app/api/auth/callback/whop/route.ts to handle this:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/server';
import { getAppBaseUrl } from '@/lib/app-url';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const state = searchParams.get('state');
  const error = searchParams.get('error');
  const baseUrl = getAppBaseUrl();

  if (error) {
    return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(error)}`);
  }

  // Read and validate PKCE data from the cookie
  const pkceCookie = request.cookies.get('whop_oauth_pkce');
  if (!pkceCookie?.value || !code) {
    return NextResponse.redirect(`${baseUrl}/login?error=invalid_state`);
  }

  const pkce = JSON.parse(pkceCookie.value);
  if (pkce.state !== state) {
    return NextResponse.redirect(`${baseUrl}/login?error=state_mismatch`);
  }

  const clientId = process.env.WHOP_OAUTH_CLIENT_ID!;
  const clientSecret = process.env.WHOP_OAUTH_CLIENT_SECRET!;
  const redirectUri = process.env.NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI!;

  // Exchange the authorization code for tokens
  const tokenRes = await fetch('https://api.whop.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      redirect_uri: redirectUri,
      code_verifier: pkce.codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    const err = await tokenRes.text();
    console.error('Whop token exchange failed:', err);
    return NextResponse.redirect(`${baseUrl}/login?error=token_exchange_failed`);
  }

  const tokens = await tokenRes.json();

  // Fetch the user's Whop profile
  const userRes = await fetch('https://api.whop.com/oauth/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userRes.ok) {
    return NextResponse.redirect(`${baseUrl}/login?error=userinfo_failed`);
  }

  const whopUser = await userRes.json();
  const supabaseAdmin = createAdminClient();

  // Check if a Supabase user with this email already exists
  const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
  const existingUser = existingUsers?.users.find(u => u.email === whopUser.email);

  let supabaseUserId: string;

  if (existingUser) {
    supabaseUserId = existingUser.id;
  } else {
    // Create a new Supabase user for this Whop account
    const { data: newUser, error: createError } = await supabaseAdmin.auth.admin.createUser({
      email: whopUser.email,
      email_confirm: true,
      user_metadata: {
        full_name: whopUser.name,
        whop_user_id: whopUser.id,
      },
    });

    if (createError || !newUser.user) {
      console.error('Failed to create Supabase user:', createError);
      return NextResponse.redirect(`${baseUrl}/login?error=user_creation_failed`);
    }

    supabaseUserId = newUser.user.id;
  }

  // Store the Whop user ID and refresh token on the profile for chat token generation
  await supabaseAdmin
    .from('profiles')
    .update({
      whop_user_id: whopUser.id,
      whop_refresh_token: tokens.refresh_token,
    })
    .eq('user_id', supabaseUserId);

  // Generate a magic link to sign the user into Supabase
  const { data: magicLink, error: magicError } = await supabaseAdmin.auth.admin.generateLink({
    type: 'magiclink',
    email: whopUser.email,
  });

  if (magicError || !magicLink.properties?.hashed_token) {
    return NextResponse.redirect(`${baseUrl}/login?error=session_creation_failed`);
  }

  const response = NextResponse.redirect(`${baseUrl}/account`);

  // Clear the PKCE cookie
  response.cookies.set('whop_oauth_pkce', '', { maxAge: 0, path: '/' });

  return response;
}

Checkpoint 4

At this point you should have:

  • src/app/login/page.tsx created with email/password login
  • src/app/api/auth/whop/authorize/route.ts created with PKCE and chat scopes
  • src/app/api/auth/callback/whop/route.ts created to handle the OAuth callback
  • Confirmed that WHOP_OAUTH_CLIENT_ID and WHOP_OAUTH_CLIENT_SECRET are set in .env.local

Step 5: Seller onboarding

When a user decides to become a seller, they need two things: a merchant account with Whop (so they can receive payments) and a completed identity verification (KYC).

With Whop's Embedded Elements, the entire KYC flow happens inside a modal on your site so the user never leaves your website.

Creating the connected company

When a seller clicks "Become a seller," your backend creates a Whop "connected company" under your platform's parent company. This is the seller's sub-merchant account which receives payments and manages payouts.

Create src/app/api/sell/onboard/route.ts:

route.ts
import { NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function GET() {
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const supabaseAdmin = createAdminClient();

  const apiKey = process.env.WHOP_API_KEY;
  const platformCompanyId = process.env.WHOP_PLATFORM_COMPANY_ID;

  if (!apiKey || !platformCompanyId) {
    return NextResponse.json(
      { error: 'Whop is not configured. Check WHOP_API_KEY and WHOP_PLATFORM_COMPANY_ID.' },
      { status: 500 }
    );
  }

  const whop = new Whop({ apiKey });

  // Check if this seller already has a connected company
  const { data: existingSeller } = await supabaseAdmin
    .from('seller_accounts')
    .select('id, whop_company_id, kyc_status')
    .eq('user_id', user.id)
    .single();

  if (existingSeller?.whop_company_id) {
    return NextResponse.json({
      companyId: existingSeller.whop_company_id,
      kycStatus: existingSeller.kyc_status,
    });
  }

  // New seller — fetch their profile for the company name
  const { data: profile } = await supabaseAdmin
    .from('profiles')
    .select('display_name, email')
    .eq('user_id', user.id)
    .single();

  // Create a new connected company under the platform
  const company = await whop.companies.create({
    email: user.email!,
    title: profile?.display_name || user.email?.split('@')[0] || 'Seller',
    parent_company_id: platformCompanyId,
    metadata: {
      internal_user_id: user.id,
    },
  });

  // Store the company ID in the seller_accounts table
  await supabaseAdmin.from('seller_accounts').insert({
    user_id: user.id,
    whop_company_id: company.id,
    kyc_status: 'unstarted',
  });

  return NextResponse.json({
    companyId: company.id,
    kycStatus: 'unstarted',
  });
}

The route returns the seller's companyId and current kycStatus. The frontend passes that company ID into the embedded <VerifyElement> (covered later in this section), which mounts the KYC iframe directly inside your app.

Checking KYC status

You need a way to check a seller's verification status from Whop's Ledger API. Create src/lib/whop-verification.ts:

whop-verification.ts
import Whop from '@whop/sdk';

export interface VerificationResult {
  verified: boolean;
  status?: string;
  error?: string;
}

export async function getWhopVerificationStatus(
  whopCompanyId: string,
  apiKey: string
): Promise<VerificationResult> {
  try {
    const whop = new Whop({ apiKey });
    const ledger = await whop.ledgerAccounts.retrieve(whopCompanyId);
    const payout = ledger.payout_account_details;

    if (!payout?.latest_verification) {
      return { verified: false, status: 'not_started' };
    }

    const verStatus = payout.latest_verification.status;
    // Whop uses "approved" or "verified" for completed KYC
    const verified = verStatus === 'verified' || verStatus === 'approved';

    return { verified, status: verStatus };
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : 'Unknown error';
    console.error('[whop-verification] Ledger fetch error:', msg);
    return { verified: false, error: msg };
  }
}

Syncing KYC status to the database

When a seller completes verification, you need to update your local database so the KYC trigger knows they are cleared to publish. Create src/app/api/sell/kyc/sync/route.ts:

route.ts
import { NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';
import { getWhopVerificationStatus } from '@/lib/whop-verification';

export async function POST() {
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: seller, error: sellerError } = await supabase
    .from('seller_accounts')
    .select('id, kyc_status, whop_company_id')
    .eq('user_id', user.id)
    .single();

  if (sellerError || !seller) {
    return NextResponse.json({ error: 'Seller account not found' }, { status: 404 });
  }

  if (!seller.whop_company_id) {
    return NextResponse.json({ verified: false, synced: false, reason: 'no_company' });
  }

  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 500 });
  }

  const { verified, status, error: verError } = await getWhopVerificationStatus(
    seller.whop_company_id,
    apiKey
  );

  if (verError) {
    return NextResponse.json({ verified: false, synced: false, error: verError }, { status: 502 });
  }

  if (verified && seller.kyc_status !== 'verified') {
    const supabaseAdmin = createAdminClient();
    await supabaseAdmin
      .from('seller_accounts')
      .update({
        kyc_status: 'verified',
        kyc_verified_at: new Date().toISOString(),
        payout_enabled: true,
      })
      .eq('id', seller.id);
  }

  return NextResponse.json({ verified, status, synced: true });
}

Creating a payout session token

The embedded KYC and payout components require an access token scoped to the seller's connected company. This token is short-lived and generated on demand. Create src/app/api/sell/payouts-token/route.ts:

route.ts
import { NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createClient } from '@/lib/supabase/server';
import { getAppBaseUrl } from '@/lib/app-url';

export async function GET() {
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: seller, error: sellerError } = await supabase
    .from('seller_accounts')
    .select('whop_company_id')
    .eq('user_id', user.id)
    .single();

  if (sellerError || !seller?.whop_company_id) {
    return NextResponse.json(
      { error: 'Complete seller onboarding before accessing payouts.' },
      { status: 400 }
    );
  }

  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 500 });
  }

  const whop = new Whop({ apiKey });
  const { token } = await whop.accessTokens.create({
    company_id: seller.whop_company_id,
  });

  const baseUrl = getAppBaseUrl();
  return NextResponse.json({
    token,
    companyId: seller.whop_company_id,
    redirectUrl: `${baseUrl}/sell/dashboard`,
  });
}

The embedded verification UI

Now that the backend is ready, you can render the KYC flow inside a modal using Whop's embedded components. The user uploads their ID, takes a selfie, and gets verified.

Create src/components/sell/WhopVerificationEmbed.tsx:

WhopVerificationEmbed.tsx
'use client';

import { useEffect, useRef } from 'react';
import { loadWhopElements } from '@whop/embedded-components-vanilla-js';
import {
  Elements,
  PayoutsSession,
  VerifyElement,
} from '@whop/embedded-components-react-js';

interface WhopVerificationEmbedProps {
  companyId: string;
  onComplete?: () => void;
  onClose?: () => void;
}

export function WhopVerificationEmbed({
  companyId,
  onComplete,
  onClose,
}: WhopVerificationEmbedProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  // Force the embedded iframe to fill the container
  useEffect(() => {
    const style = document.createElement('style');
    style.textContent = `
      whop-verify, whop-verify iframe {
        width: 100% !important;
        min-height: 500px !important;
      }
    `;
    document.head.appendChild(style);
    return () => document.head.removeChild(style);
  }, []);

  const getToken = async (): Promise<string> => {
    const res = await fetch('/api/sell/payouts-token');
    if (!res.ok) throw new Error('Failed to fetch payout token');
    const data = await res.json();
    return data.token;
  };

  return (
    <div ref={containerRef} className="w-full min-h-[500px]">
      <Elements loader={loadWhopElements}>
        <PayoutsSession companyId={companyId} getToken={getToken}>
          <VerifyElement
            includeControls
            onVerificationSubmitted={async () => {
              // Sync the KYC status to the database immediately
              await fetch('/api/sell/kyc/sync', { method: 'POST' });
              onComplete?.();
            }}
            onClose={onClose}
          />
        </PayoutsSession>
      </Elements>
    </div>
  );
}

Checkpoint 5

At this point you should have:

  • src/app/api/sell/onboard/route.ts created
  • src/lib/whop-verification.ts created
  • src/app/api/sell/kyc/sync/route.ts created
  • src/app/api/sell/payouts-token/route.ts created
  • src/components/sell/WhopVerificationEmbed.tsx created
  • Tested the onboarding flow end-to-end in sandbox mode

Step 6: Creating and managing gigs

Once a seller is verified, they can create gigs. A gig has a title, description, category, and up to three pricing tiers (Basic, Standard, Premium), plus optional add-ons called extras.

Gig status flow

When a seller creates a gig, it starts as a draft. They can edit it freely and then submit it for admin review. An admin approves or rejects it, and only then does it become published.

Even if an admin approves a gig, the database trigger will still reject the publish operation if the seller's KYC status is not verified. The trigger is always the final check.

Row level security for gigs

The gigs table uses RLS to control visibility. Published gigs are visible to everyone. Drafts are only visible to the seller who owns them and admins. This policy is defined in supabase/migrations/20250311000000_initial_schema.sql:

20250311000000_initial_schema.sql
-- Published gigs are public; drafts are only visible to the seller and admins
create policy "gigs public read"
on public.gigs for select
using (
  status = 'published'
  or seller_user_id = public.current_user_id()
  or public.is_admin()
);

-- Only the seller (or an admin) can create gigs
create policy "gigs seller insert"
on public.gigs for insert
with check (seller_user_id = public.current_user_id() or public.is_admin());

-- Only the seller (or an admin) can update gigs
create policy "gigs seller update"
on public.gigs for update
using (seller_user_id = public.current_user_id() or public.is_admin())
with check (seller_user_id = public.current_user_id() or public.is_admin());

-- Only the seller (or an admin) can delete gigs
create policy "gigs seller delete"
on public.gigs for delete
using (seller_user_id = public.current_user_id() or public.is_admin());

Checkpoint 6

At this point you should have:

  • Confirmed that gig RLS policies are applied in your database
  • Tested that an unverified seller cannot publish a gig (the database trigger should reject it)
  • Tested that a verified seller can successfully publish a gig

Step 7: The slide-out checkout experience

When a buyer clicks "Purchase" on a gig, you do not send them to a new page. A panel slides out from the right side of the screen. The buyer selects their package and any extras, then pays.

The checkout flow has three phases: creating a checkout session on the server, rendering the Whop checkout embed in the slide-out panel, and confirming the order after payment.

Phase 1: Creating the checkout session

The backend creates a Whop checkout configuration on the seller's connected company. This is where the platform fee is applied. Create src/app/api/checkout/create/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(request: NextRequest) {
  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 500 });
  }

  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  // user may be null for guest checkout — that is intentional

  let body: {
    gigId: string;
    gigTitle: string;
    packageId: string;
    packageTitle: string;
    quantity?: number;
    extras?: Array<{ id: string; title: string; price_cents: number }>;
    totalCents: number;
  };

  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const { gigId, gigTitle, packageId, packageTitle, quantity = 1, extras = [], totalCents } = body;

  if (!gigId || !packageId || !totalCents) {
    return NextResponse.json({ error: 'Missing required fields: gigId, packageId, totalCents' }, { status: 400 });
  }

  // Look up the gig to get the seller's user ID
  const { data: gig, error: gigError } = await supabase
    .from('gigs')
    .select('id, seller_user_id, requirements_schema')
    .eq('id', gigId)
    .single();

  if (gigError || !gig) {
    return NextResponse.json({ error: 'Gig not found' }, { status: 404 });
  }

  // Use the admin client to look up the seller's Whop company ID
  const supabaseAdmin = createAdminClient();
  const { data: seller, error: sellerError } = await supabaseAdmin
    .from('seller_accounts')
    .select('whop_company_id')
    .eq('user_id', gig.seller_user_id)
    .single();

  if (sellerError || !seller?.whop_company_id) {
    return NextResponse.json(
      { error: 'Seller has not connected a Whop account' },
      { status: 400 }
    );
  }

  // Calculate the platform fee
  const totalDollars = totalCents / 100;
  const platformFeeBps = parseInt(process.env.PLATFORM_FEE_BPS || '1000', 10);
  const applicationFee = Math.round((totalCents * platformFeeBps) / 10000) / 100;

  const client = new Whop({ apiKey });

  // Create the checkout configuration on the seller's connected company.
  // The application_fee_amount is automatically routed to your platform company.
  const checkoutConfig = await client.checkoutConfigurations.create({
    mode: 'payment',
    plan: {
      company_id: seller.whop_company_id,
      currency: 'usd',
      initial_price: totalDollars,
      plan_type: 'one_time',
      application_fee_amount: applicationFee,
      title: gigTitle,
      product: {
        external_identifier: `gig_${gig.id}_pkg_${packageId}`,
        title: gigTitle,
      },
    },
    metadata: {
      gig_id: gig.id,
      package_id: packageId,
      package_title: packageTitle,
      quantity: String(quantity),
      extras_ids: extras.map((e) => e.id).join(','),
      buyer_user_id: user?.id ?? '',
    },
  });

  return NextResponse.json({
    sessionId: checkoutConfig.id,
    purchaseUrl: checkoutConfig.purchase_url,
  });
}

Phase 2: The slide-out panel

The OrderOptionsSlideOut component manages the entire checkout UX. It has two internal steps: options (package and extras selection) and checkout (the Whop embed). Create src/components/gig/OrderOptionsSlideOut.tsx:

OrderOptionsSlideOut.tsx
'use client';

import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { WhopCheckoutEmbed } from '@whop/checkout/react';
import { getAppBaseUrl } from '@/lib/app-url';

interface Extra {
  id: string;
  title: string;
  price_cents: number;
  max_quantity: number;
}

interface Package {
  id: string;
  tier: 'basic' | 'standard' | 'premium';
  title: string;
  description: string;
  price_cents: number;
  delivery_days: number;
  revisions_included: number;
}

type Step = 'options' | 'checkout';

interface OrderOptionsSlideOutProps {
  gigId: string;
  gigTitle: string;
  packages: Package[];
  extras: Extra[];
  isOpen: boolean;
  onClose: () => void;
}

export function OrderOptionsSlideOut({
  gigId,
  gigTitle,
  packages,
  extras,
  isOpen,
  onClose,
}: OrderOptionsSlideOutProps) {
  const router = useRouter();
  const [step, setStep] = useState<Step>('options');
  const [selectedPackage, setSelectedPackage] = useState<Package | null>(packages[0] ?? null);
  const [selectedExtras, setSelectedExtras] = useState<Extra[]>([]);
  const [quantity, setQuantity] = useState(1);
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Reset state when the panel opens
  useEffect(() => {
    if (isOpen) {
      setStep('options');
      setSessionId(null);
      setError(null);
      setSelectedPackage(packages[0] ?? null);
      setSelectedExtras([]);
      setQuantity(1);
    }
  }, [isOpen, packages]);

  // Close on Escape key
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    if (isOpen) window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  const pkg = selectedPackage;
  const extraTotal = selectedExtras.reduce((sum, e) => sum + e.price_cents, 0);
  const totalCents = pkg ? pkg.price_cents * quantity + extraTotal : 0;

  const handleContinue = async () => {
    if (!pkg) return;
    setLoading(true);
    setError(null);

    try {
      const res = await fetch('/api/checkout/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          gigId,
          gigTitle,
          packageId: pkg.id,
          packageTitle: pkg.title,
          quantity,
          extras: selectedExtras.map((e) => ({
            id: e.id,
            title: e.title,
            price_cents: e.price_cents,
          })),
          totalCents,
        }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error ?? 'Failed to create checkout session');
      }

      const { sessionId: id } = await res.json();
      setSessionId(id);
      setStep('checkout');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  const handleCheckoutComplete = useCallback(
    async (_planOrSessionId: string, receiptOrSetupId?: string) => {
      onClose();
      const receipt = receiptOrSetupId ?? _planOrSessionId;

      try {
        const res = await fetch('/api/checkout/confirm', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            session_id: sessionId,
            receipt_id: receipt,
          }),
        });

        const data = await res.json();
        if (res.ok && data.orderId) {
          router.push(`/checkout/complete?order_id=${data.orderId}`);
        }
      } catch (err) {
        console.error('Order confirmation failed:', err);
      }
    },
    [onClose, router, sessionId]
  );

  if (!isOpen) return null;

  const returnUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/checkout/complete`;

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/40 z-40"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Slide-out panel */}
      <div className="fixed right-0 top-0 h-full w-full max-w-md bg-white z-50 shadow-xl flex flex-col overflow-y-auto">
        <div className="flex items-center justify-between p-4 border-b">
          <h2 className="text-lg font-semibold">
            {step === 'options' ? 'Choose your package' : 'Complete payment'}
          </h2>
          <button onClick={onClose} aria-label="Close" className="text-gray-500 hover:text-gray-900">
            ✕
          </button>
        </div>

        <div className="flex-1 p-4">
          {step === 'options' && (
            <div className="space-y-4">
              {/* Package selection */}
              <div className="space-y-2">
                {packages.map((p) => (
                  <button
                    key={p.id}
                    onClick={() => setSelectedPackage(p)}
                    className={`w-full text-left border rounded-lg p-3 transition ${
                      selectedPackage?.id === p.id
                        ? 'border-black bg-gray-50'
                        : 'border-gray-200 hover:border-gray-400'
                    }`}
                  >
                    <div className="flex justify-between">
                      <span className="font-medium capitalize">{p.tier}</span>
                      <span className="font-semibold">${(p.price_cents / 100).toFixed(2)}</span>
                    </div>
                    <p className="text-sm text-gray-500 mt-1">{p.description}</p>
                    <p className="text-xs text-gray-400 mt-1">
                      {p.delivery_days} day{p.delivery_days !== 1 ? 's' : ''} delivery ·{' '}
                      {p.revisions_included} revision{p.revisions_included !== 1 ? 's' : ''}
                    </p>
                  </button>
                ))}
              </div>

              {/* Extras */}
              {extras.length > 0 && (
                <div>
                  <h3 className="font-medium mb-2">Add extras</h3>
                  <div className="space-y-2">
                    {extras.map((e) => (
                      <label key={e.id} className="flex items-center gap-3 cursor-pointer">
                        <input
                          type="checkbox"
                          checked={selectedExtras.some((x) => x.id === e.id)}
                          onChange={(ev) => {
                            if (ev.target.checked) {
                              setSelectedExtras((prev) => [...prev, e]);
                            } else {
                              setSelectedExtras((prev) => prev.filter((x) => x.id !== e.id));
                            }
                          }}
                        />
                        <span className="flex-1 text-sm">{e.title}</span>
                        <span className="text-sm font-medium">
                          +${(e.price_cents / 100).toFixed(2)}
                        </span>
                      </label>
                    ))}
                  </div>
                </div>
              )}

              {error && <p className="text-red-500 text-sm">{error}</p>}

              <div className="border-t pt-4">
                <div className="flex justify-between font-semibold mb-3">
                  <span>Total</span>
                  <span>${(totalCents / 100).toFixed(2)}</span>
                </div>
                <button
                  onClick={handleContinue}
                  disabled={loading || !pkg}
                  className="w-full bg-black text-white rounded-lg py-3 font-medium disabled:opacity-50"
                >
                  {loading ? 'Setting up checkout…' : 'Continue to payment'}
                </button>
              </div>
            </div>
          )}

          {step === 'checkout' && sessionId && (
            <div className="min-h-[420px]">
              <WhopCheckoutEmbed
                sessionId={sessionId}
                returnUrl={returnUrl}
                onComplete={handleCheckoutComplete}
                theme="light"
                fallback={<div className="text-center py-8 text-gray-500">Loading checkout…</div>}
              />
            </div>
          )}
        </div>
      </div>
    </>
  );
}

Phase 3: Confirming the order

After payment completes, the onComplete callback calls your confirm endpoint. This endpoint retrieves the checkout metadata from Whop and creates the local order record. Create src/app/api/checkout/confirm/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createAdminClient } from '@/lib/supabase/server';

export async function POST(request: NextRequest) {
  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 500 });
  }

  let body: { session_id: string; receipt_id?: string };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const { session_id, receipt_id } = body;
  if (!session_id) {
    return NextResponse.json({ error: 'session_id is required' }, { status: 400 });
  }

  const client = new Whop({ apiKey });
  const supabaseAdmin = createAdminClient();

  // Retrieve the checkout configuration to get the metadata we stored earlier
  let config;
  try {
    config = await client.checkoutConfigurations.retrieve(session_id);
  } catch (err) {
    console.error('[checkout/confirm] Failed to retrieve checkout config:', err);
    return NextResponse.json({ error: 'Checkout session not found' }, { status: 404 });
  }

  const meta = (config.metadata ?? {}) as Record<string, string>;
  const gigId = meta.gig_id;
  const packageId = meta.package_id;
  const buyerUserId = meta.buyer_user_id || null;

  if (!gigId || !packageId) {
    return NextResponse.json({ error: 'Checkout metadata is incomplete' }, { status: 400 });
  }

  // Look up the gig to get the seller and requirements schema
  const { data: gig, error: gigError } = await supabaseAdmin
    .from('gigs')
    .select('id, seller_user_id, title, requirements_schema')
    .eq('id', gigId)
    .single();

  if (gigError || !gig) {
    return NextResponse.json({ error: 'Gig not found' }, { status: 404 });
  }

  // Check if an order already exists for this checkout session (idempotency)
  const { data: existingOrder } = await supabaseAdmin
    .from('orders')
    .select('id')
    .eq('whop_checkout_config_id', session_id)
    .single();

  if (existingOrder) {
    return NextResponse.json({ orderId: existingOrder.id });
  }

  // Create the order
  const { data: order, error: orderError } = await supabaseAdmin
    .from('orders')
    .insert({
      gig_id: gigId,
      package_id: packageId,
      seller_user_id: gig.seller_user_id,
      buyer_user_id: buyerUserId,
      status: 'awaiting_requirements',
      requirements_schema: gig.requirements_schema ?? {},
      whop_checkout_config_id: session_id,
    })
    .select('id')
    .single();

  if (orderError || !order) {
    console.error('[checkout/confirm] Failed to create order:', orderError);
    return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
  }

  // Notify the seller
  await supabaseAdmin.from('notifications').insert([
    {
      user_id: gig.seller_user_id,
      type: 'order',
      title: 'New order received',
      body: gig.title,
      link: '/sell/orders',
    },
    // Notify the buyer if they are logged in
    ...(buyerUserId
      ? [
          {
            user_id: buyerUserId,
            type: 'order',
            title: 'Order confirmed',
            body: gig.title,
            link: `/orders/${order.id}`,
          },
        ]
      : []),
  ]);

  return NextResponse.json({ orderId: order.id });
}
The confirm endpoint is a convenience for the user experience. It creates the order immediately so the buyer sees a confirmation page. However, it should not be your only source of truth.

Use Whop webhooks to validate payments and reconcile orders reliably. A buyer could close their browser before the confirm endpoint fires.

Checkpoint 7

At this point you should have:

  • src/app/api/checkout/create/route.ts created
  • src/components/gig/OrderOptionsSlideOut.tsx created
  • src/app/api/checkout/confirm/route.ts created
  • Tested a sandbox checkout end-to-end using Whop's test card numbers
  • Confirmed that an order record is created in the database after payment

Step 8: The order lifecycle

Every order goes through a series of states. Here is the complete flow:

FromTriggerTo
awaiting_requirementsBuyer submits the project briefin_progress
in_progressSeller submits a deliverydelivered
deliveredBuyer accepts the deliverycompleted
deliveredBuyer requests a revisionrevision_requested
revision_requestedSeller resubmits a deliverydelivered
in_progress / deliveredEither party opens a disputedisputed
disputedDispute resolved in buyer's favorrefunded

Each state transition has a dedicated API route. Every route follows the same pattern: authenticate the user, verify they have permission to perform the action, validate the current order state, update the database, and send a notification.

Submitting requirements (buyer)

The buyer submits their project brief, which moves the order from awaiting_requirements to in_progress. Create src/app/api/orders/[id]/requirements/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: orderId } = await params;
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  // Load the order
  const { data: order, error: orderError } = await supabase
    .from('orders')
    .select('id, status, buyer_user_id, seller_user_id')
    .eq('id', orderId)
    .single();

  if (orderError || !order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  // Allow authenticated buyers or guests (buyer_user_id is null for guest orders)
  const isAuthenticatedBuyer = user && order.buyer_user_id === user.id;
  const isGuestOrder = !order.buyer_user_id;

  if (!isAuthenticatedBuyer && !isGuestOrder) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  if (order.status !== 'awaiting_requirements') {
    return NextResponse.json(
      { error: `Requirements already submitted. Order is currently: ${order.status}` },
      { status: 400 }
    );
  }

  let body: { answers?: Record<string, unknown>; attachments?: unknown[] };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const answers = body.answers ?? {};
  const attachments = Array.isArray(body.attachments) ? body.attachments : [];

  const supabaseAdmin = createAdminClient();

  // Upsert the requirements (in case the buyer is re-submitting)
  await supabaseAdmin.from('order_requirements').upsert(
    {
      order_id: orderId,
      answers,
      attachments,
      submitted_at: new Date().toISOString(),
    },
    { onConflict: 'order_id' }
  );

  // Move the order to in_progress
  await supabaseAdmin
    .from('orders')
    .update({ status: 'in_progress', updated_at: new Date().toISOString() })
    .eq('id', orderId);

  // Notify the seller
  await supabaseAdmin.from('notifications').insert({
    user_id: order.seller_user_id,
    type: 'order',
    title: 'Requirements submitted',
    body: `Order ${orderId.slice(0, 8).toUpperCase()} is ready to start`,
    link: `/sell/orders/${orderId}`,
  });

  return NextResponse.json({ ok: true });
}

Delivering the work (seller)

The seller uploads their deliverables, moving the order to delivered. Create src/app/api/orders/[id]/deliver/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: orderId } = await params;
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: order, error: orderError } = await supabase
    .from('orders')
    .select('id, status, seller_user_id, buyer_user_id, gig_id')
    .eq('id', orderId)
    .single();

  if (orderError || !order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  if (order.seller_user_id !== user.id) {
    return NextResponse.json({ error: 'Forbidden — only the seller can deliver' }, { status: 403 });
  }

  const validStatuses = ['in_progress', 'revision_requested'];
  if (!validStatuses.includes(order.status)) {
    return NextResponse.json(
      { error: `Order cannot be delivered in its current state: ${order.status}` },
      { status: 400 }
    );
  }

  let body: { message?: string; items?: unknown[] };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const message = typeof body.message === 'string' ? body.message.trim() : null;
  const items = Array.isArray(body.items) ? body.items : [];

  const supabaseAdmin = createAdminClient();

  await supabaseAdmin.from('order_deliveries').insert({
    order_id: orderId,
    message: message || null,
    items,
  });

  await supabaseAdmin
    .from('orders')
    .update({ status: 'delivered', updated_at: new Date().toISOString() })
    .eq('id', orderId);

  // Look up the gig title for the notification
  const { data: gig } = await supabaseAdmin
    .from('gigs')
    .select('title')
    .eq('id', order.gig_id)
    .single();

  if (order.buyer_user_id) {
    await supabaseAdmin.from('notifications').insert({
      user_id: order.buyer_user_id,
      type: 'order',
      title: 'Delivery received',
      body: gig?.title ?? 'Your order has been delivered',
      link: `/orders/${orderId}`,
    });
  }

  return NextResponse.json({ ok: true });
}

Requesting a revision (buyer)

If the buyer is not satisfied, they can request a revision. Create src/app/api/orders/[id]/request-revision/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: orderId } = await params;
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: order, error: orderError } = await supabase
    .from('orders')
    .select('id, status, buyer_user_id, seller_user_id')
    .eq('id', orderId)
    .single();

  if (orderError || !order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  if (order.buyer_user_id !== user.id) {
    return NextResponse.json({ error: 'Forbidden — only the buyer can request a revision' }, { status: 403 });
  }

  if (order.status !== 'delivered') {
    return NextResponse.json(
      { error: `Revisions can only be requested on delivered orders. Current status: ${order.status}` },
      { status: 400 }
    );
  }

  const supabaseAdmin = createAdminClient();

  await supabaseAdmin
    .from('orders')
    .update({ status: 'revision_requested', updated_at: new Date().toISOString() })
    .eq('id', orderId);

  await supabaseAdmin.from('notifications').insert({
    user_id: order.seller_user_id,
    type: 'order',
    title: 'Revision requested',
    body: `Order ${orderId.slice(0, 8).toUpperCase()}`,
    link: `/sell/orders/${orderId}`,
  });

  return NextResponse.json({ ok: true });
}

Accepting the delivery (buyer)

When the buyer is satisfied, they accept the delivery, completing the order. Create src/app/api/orders/[id]/accept-delivery/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: orderId } = await params;
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: order, error: orderError } = await supabase
    .from('orders')
    .select('id, status, buyer_user_id, seller_user_id')
    .eq('id', orderId)
    .single();

  if (orderError || !order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  if (order.buyer_user_id !== user.id) {
    return NextResponse.json({ error: 'Forbidden — only the buyer can accept delivery' }, { status: 403 });
  }

  if (order.status !== 'delivered') {
    return NextResponse.json(
      { error: `Order is not in delivered state. Current status: ${order.status}` },
      { status: 400 }
    );
  }

  const supabaseAdmin = createAdminClient();
  const now = new Date().toISOString();

  await supabaseAdmin
    .from('orders')
    .update({ status: 'completed', completed_at: now, updated_at: now })
    .eq('id', orderId);

  await supabaseAdmin.from('notifications').insert({
    user_id: order.seller_user_id,
    type: 'order',
    title: 'Delivery accepted',
    body: `Order ${orderId.slice(0, 8).toUpperCase()} is complete`,
    link: `/sell/orders/${orderId}`,
  });

  return NextResponse.json({ ok: true });
}

Leaving a review (buyer)

After delivery or completion, the buyer can leave a 1–5 star review. If the order is still in delivered state, leaving a review also completes it. Create src/app/api/orders/[id]/review/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient, createAdminClient } from '@/lib/supabase/server';

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: orderId } = await params;
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  let body: { rating?: number; body?: string };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const rating = typeof body.rating === 'number' ? Math.round(body.rating) : null;
  if (!rating || rating < 1 || rating > 5) {
    return NextResponse.json({ error: 'Rating must be a number between 1 and 5' }, { status: 400 });
  }

  const { data: order, error: orderError } = await supabase
    .from('orders')
    .select('id, status, buyer_user_id, seller_user_id, gig_id')
    .eq('id', orderId)
    .single();

  if (orderError || !order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  if (order.buyer_user_id !== user.id) {
    return NextResponse.json({ error: 'Forbidden — only the buyer can leave a review' }, { status: 403 });
  }

  if (!['delivered', 'completed'].includes(order.status)) {
    return NextResponse.json(
      { error: 'Reviews can only be left after delivery' },
      { status: 400 }
    );
  }

  const supabaseAdmin = createAdminClient();

  // Check for an existing review to prevent duplicates
  const { data: existingReview } = await supabaseAdmin
    .from('reviews')
    .select('id')
    .eq('order_id', orderId)
    .single();

  if (existingReview) {
    return NextResponse.json({ error: 'A review has already been submitted for this order' }, { status: 409 });
  }

  await supabaseAdmin.from('reviews').insert({
    order_id: orderId,
    gig_id: order.gig_id,
    seller_user_id: order.seller_user_id,
    buyer_user_id: user.id,
    rating,
    body: typeof body.body === 'string' ? body.body.trim() || null : null,
  });

  // Auto-complete the order if it was still in delivered state
  if (order.status === 'delivered') {
    const now = new Date().toISOString();
    await supabaseAdmin
      .from('orders')
      .update({ status: 'completed', completed_at: now, updated_at: now })
      .eq('id', orderId);
  }

  return NextResponse.json({ ok: true });
}

Checkpoint 8

At this point you should have:

  • src/app/api/orders/[id]/requirements/route.ts created
  • src/app/api/orders/[id]/deliver/route.ts created
  • src/app/api/orders/[id]/request-revision/route.ts created
  • src/app/api/orders/[id]/accept-delivery/route.ts created
  • src/app/api/orders/[id]/review/route.ts created
  • Tested the full order lifecycle end-to-end in sandbox mode

Step 9: Webhooks

The checkout confirm endpoint creates an order immediately so the buyer sees a confirmation page. But the confirm request can fail. A closed browser tab or a network error leaves the payment processed without a matching order record. This is why we use webhooks.

Whop sends a payment_succeeded event to your server for every successful payment, regardless of what the frontend does. Your webhook handler should be the authoritative source for order creation and payment reconciliation.

Subscribing to webhook events

In your Whop dashboard, go to Developer → Webhooks and create a new webhook endpoint pointing to https://your-app.vercel.app/api/webhooks/whop. Subscribe to the following events:

EventWhat to do
payment_succeededUpsert whop_payments, reconcile orders, notify parties
payment_failedAlert buyer, prevent false order creation
payment_pendingMark checkout as pending, poll if needed
refund_created / refund_updatedUpdate order to refunded, notify parties
dispute_created / dispute_updatedSet order to disputed, surface in admin dashboard
dispute_alert_createdEarly warning. Alert admin, consider pausing seller
verification_succeededSync seller_accounts.kyc_status = 'verified'
payout_method_createdMark payout method exists, unblock withdrawals
withdrawal_created / withdrawal_updatedUpdate seller payout activity

Webhook signature verification

Before processing any webhook, you must verify that it actually came from Whop. Whop follows the Standard Webhooks specification and provides a WHOP_WEBHOOK_SECRET for this purpose. The Whop SDK handles verification for you.

Create src/app/api/webhooks/whop/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createAdminClient } from '@/lib/supabase/server';

// Initialize the Whop client with your webhook secret for signature verification.
// The SDK uses this to verify the webhook-signature header on every request.
const whop = new Whop({
  apiKey: process.env.WHOP_API_KEY!,
  webhookKey: process.env.WHOP_WEBHOOK_SECRET
    ? btoa(process.env.WHOP_WEBHOOK_SECRET)
    : undefined,
});

export async function POST(request: NextRequest) {
  const bodyText = await request.text();
  const headers = Object.fromEntries(request.headers);

  // Step 1: Verify the signature and parse the payload.
  // If the signature is invalid, the SDK will throw and we return 401.
  let webhook: { id: string; type: string; company_id?: string; data: unknown };
  try {
    webhook = whop.webhooks.unwrap(bodyText, { headers }) as typeof webhook;
  } catch (err) {
    console.error('[webhook] Signature verification failed:', err);
    return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 });
  }

  const supabaseAdmin = createAdminClient();

  // Step 2: Store the event idempotently.
  // The webhook_id unique constraint prevents duplicate processing.
  // If this event has already been processed, return 200 immediately.
  const { error: insertError } = await supabaseAdmin.from('webhook_events').insert({
    webhook_id: webhook.id,
    type: webhook.type,
    company_id: webhook.company_id ?? null,
    payload: JSON.parse(bodyText),
  });

  if (insertError?.code === '23505') {
    // Unique constraint violation — already processed. This is expected for retries.
    return NextResponse.json({ ok: true });
  }

  if (insertError) {
    console.error('[webhook] Failed to store event:', insertError);
    return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  }

  // Step 3: Route by event type.
  // Respond with 200 quickly — Whop will retry if you do not respond within a few seconds.
  try {
    switch (webhook.type) {
      case 'payment_succeeded':
        await handlePaymentSucceeded(webhook.data, supabaseAdmin);
        break;
      case 'verification_succeeded':
        await handleVerificationSucceeded(webhook.company_id, supabaseAdmin);
        break;
      case 'refund_created':
      case 'refund_updated':
        await handleRefund(webhook.data, supabaseAdmin);
        break;
      case 'dispute_created':
      case 'dispute_updated':
        await handleDispute(webhook.data, supabaseAdmin);
        break;
      default:
        // Log unhandled events but do not fail
        console.log(`[webhook] Unhandled event type: ${webhook.type}`);
    }
  } catch (err) {
    console.error(`[webhook] Handler error for ${webhook.type}:`, err);
    // Still return 200 to prevent Whop from retrying an event we already stored.
    // Log the error and investigate separately.
  }

  return NextResponse.json({ ok: true });
}

async function handlePaymentSucceeded(
  data: unknown,
  supabaseAdmin: ReturnType<typeof createAdminClient>
) {
  const payment = data as {
    id: string;
    company_id: string;
    status: string;
    final_amount: number;
    metadata?: Record<string, string>;
  };

  // Upsert the payment record
  await supabaseAdmin.from('whop_payments').upsert(
    {
      whop_payment_id: payment.id,
      whop_company_id: payment.company_id,
      status: payment.status,
      total_cents: payment.final_amount,
      raw: payment,
    },
    { onConflict: 'whop_payment_id' }
  );

  // If the checkout metadata includes an order ID, attach the payment to it
  const orderId = payment.metadata?.order_id;
  if (orderId) {
    await supabaseAdmin
      .from('whop_payments')
      .update({ order_id: orderId })
      .eq('whop_payment_id', payment.id);
  }
}

async function handleVerificationSucceeded(
  companyId: string | undefined,
  supabaseAdmin: ReturnType<typeof createAdminClient>
) {
  if (!companyId) return;

  await supabaseAdmin
    .from('seller_accounts')
    .update({
      kyc_status: 'verified',
      kyc_verified_at: new Date().toISOString(),
      payout_enabled: true,
    })
    .eq('whop_company_id', companyId);
}

async function handleRefund(
  data: unknown,
  supabaseAdmin: ReturnType<typeof createAdminClient>
) {
  const refund = data as { payment_id?: string };
  if (!refund.payment_id) return;

  const { data: payment } = await supabaseAdmin
    .from('whop_payments')
    .select('order_id')
    .eq('whop_payment_id', refund.payment_id)
    .single();

  if (payment?.order_id) {
    await supabaseAdmin
      .from('orders')
      .update({ status: 'refunded', updated_at: new Date().toISOString() })
      .eq('id', payment.order_id);
  }
}

async function handleDispute(
  data: unknown,
  supabaseAdmin: ReturnType<typeof createAdminClient>
) {
  const dispute = data as { payment_id?: string };
  if (!dispute.payment_id) return;

  const { data: payment } = await supabaseAdmin
    .from('whop_payments')
    .select('order_id')
    .eq('whop_payment_id', dispute.payment_id)
    .single();

  if (payment?.order_id) {
    await supabaseAdmin
      .from('orders')
      .update({ status: 'disputed', updated_at: new Date().toISOString() })
      .eq('id', payment.order_id);
  }
}
Whop cannot reach localhost:3000 directly, so if you want to test webhooks locally, use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server to the internet during development.

Register the tunnel URL as your webhook endpoint in the Whop dashboard.

Checkpoint 9

At this point you should have:

  • src/app/api/webhooks/whop/route.ts created with signature verification and idempotency
  • Webhook endpoint registered in the Whop dashboard
  • WHOP_WEBHOOK_SECRET set in .env.local
  • Tested webhook delivery using Whop's dashboard test tool or ngrok

Step 10: Seller payouts

Sellers want their money. Using Whop's embedded components, you can drop the payout management UI directly into the seller dashboard. Sellers can link their bank accounts, view their balance, and request withdrawals.

Embedding the payout UI

The SellPayoutsProvider component wraps the seller dashboard and initializes a shared Whop payout session. Any child component can then use Whop's embedded payout elements. Create src/components/sell/SellPayoutsProvider.tsx:

SellPayoutsProvider.tsx
'use client';

import { useState, useEffect, useMemo, createContext, useContext } from 'react';
import { loadWhopElements } from '@whop/embedded-components-vanilla-js';
import { Elements, PayoutsSession } from '@whop/embedded-components-react-js';

interface PayoutsConfig {
  companyId: string;
  redirectUrl: string;
}

const PayoutsContext = createContext<{ available: boolean }>({ available: false });
export const usePayouts = () => useContext(PayoutsContext);

export function SellPayoutsProvider({ children }: { children: React.ReactNode }) {
  const [config, setConfig] = useState<PayoutsConfig | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/sell/payouts-token')
      .then((res) => {
        if (!res.ok) throw new Error('Failed to fetch payout token');
        return res.json();
      })
      .then((data: { companyId: string; redirectUrl: string }) => {
        setConfig({ companyId: data.companyId, redirectUrl: data.redirectUrl });
      })
      .catch((err) => {
        console.error('[SellPayoutsProvider] Token fetch failed:', err);
        setError(err.message);
      });
  }, []);

  const loader = useMemo(() => loadWhopElements, []);

  const getToken = async (): Promise<string> => {
    const res = await fetch('/api/sell/payouts-token');
    if (!res.ok) throw new Error('Failed to refresh payout token');
    const data = await res.json();
    return data.token;
  };

  if (error || !config) {
    return (
      <PayoutsContext.Provider value={{ available: false }}>
        {children}
      </PayoutsContext.Provider>
    );
  }

  return (
    <PayoutsContext.Provider value={{ available: true }}>
      <Elements loader={loader}>
        <PayoutsSession companyId={config.companyId} getToken={getToken}>
          {children}
        </PayoutsSession>
      </Elements>
    </PayoutsContext.Provider>
  );
}

Requesting a withdrawal

To let sellers withdraw their earnings programmatically, create src/app/api/sell/withdraw/route.ts:

route.ts
import { NextRequest, NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createClient } from '@/lib/supabase/server';

export async function POST(request: NextRequest) {
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  let body: { amount: number; currency: string; payout_method_id: string };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
  }

  const { amount, currency, payout_method_id } = body;

  if (!amount || !currency || !payout_method_id) {
    return NextResponse.json(
      { error: 'Missing required fields: amount, currency, payout_method_id' },
      { status: 400 }
    );
  }

  const { data: seller, error: sellerError } = await supabase
    .from('seller_accounts')
    .select('whop_company_id, kyc_status, payout_enabled')
    .eq('user_id', user.id)
    .single();

  if (sellerError || !seller?.whop_company_id) {
    return NextResponse.json({ error: 'Seller account not found' }, { status: 404 });
  }

  if (seller.kyc_status !== 'verified' || !seller.payout_enabled) {
    return NextResponse.json(
      { error: 'Complete KYC verification before withdrawing' },
      { status: 403 }
    );
  }

  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 500 });
  }

  const whop = new Whop({ apiKey });
  const withdrawal = await whop.withdrawals.create({
    company_id: seller.whop_company_id,
    amount,
    currency,
    payout_method_id,
  });

  return NextResponse.json({
    success: true,
    withdrawalId: withdrawal.id,
    status: withdrawal.status,
    amount: withdrawal.amount,
  });
}

Your platform never touches the money. Whop routes the funds from the buyer, deducts your platform fee, and makes the remainder available for the seller to withdraw.

Checkpoint 10

At this point you should have:

  • src/components/sell/SellPayoutsProvider.tsx created
  • src/app/api/sell/withdraw/route.ts created
  • Seller dashboard wrapped with SellPayoutsProvider
  • Tested the payout flow in sandbox mode

Step 11: Embedded chat (the Whop OAuth payoff)

Earlier, you were encouraged to implement Whop OAuth and request chat scopes. Here is where that investment pays off.

When both the buyer and seller authenticated with Whop OAuth, you have their whop_user_id stored in the profiles table. This is everything you need to create a Whop DM channel between them and render a full-featured chat embed directly in the order workspace.

Creating a chat token

The embedded chat component needs an access token scoped to the current user's Whop identity.

The token generation tries three strategies in order: a direct user access token, an OAuth refresh using the stored refresh token, and a fallback to a company-scoped token for sellers.

Create src/app/api/chat/token/route.ts:

route.ts
import { NextResponse } from 'next/server';
import Whop from '@whop/sdk';
import { createClient } from '@/lib/supabase/server';
import { getAppBaseUrl } from '@/lib/app-url';

const CHAT_SCOPES = [
  'chat:message:create',
  'chat:read',
  'dms:read',
  'dms:message:manage',
  'dms:channel:manage',
  'support_chat:read',
  'support_chat:message:create',
].join(' ');

export async function GET() {
  const supabase = await createClient();
  const { data: { user }, error: authError } = await supabase.auth.getUser();

  if (authError || !user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const apiKey = process.env.WHOP_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'WHOP_API_KEY is not set' }, { status: 503 });
  }

  const { data: profile } = await supabase
    .from('profiles')
    .select('whop_user_id, whop_refresh_token')
    .eq('user_id', user.id)
    .single();

  const whop = new Whop({ apiKey });

  // Strategy 1: Direct user access token (requires whop_user_id from OAuth)
  if (profile?.whop_user_id) {
    try {
      console.log('[chat/token] Attempting user access token for', profile.whop_user_id);
      const { token } = await whop.accessTokens.create({
        user_id: profile.whop_user_id,
        scopes: CHAT_SCOPES,
      });
      return NextResponse.json({ token });
    } catch (err) {
      console.warn('[chat/token] User access token failed, trying OAuth refresh:', err);
    }
  }

  // Strategy 2: OAuth refresh token (if the user logged in with Whop OAuth)
  if (profile?.whop_refresh_token) {
    const clientId = process.env.WHOP_OAUTH_CLIENT_ID;
    const clientSecret = process.env.WHOP_OAUTH_CLIENT_SECRET;
    const baseUrl = getAppBaseUrl();

    if (clientId && clientSecret) {
      try {
        console.log('[chat/token] Attempting OAuth refresh token exchange');
        const tokenRes = await fetch('https://api.whop.com/oauth/token', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
          },
          body: JSON.stringify({
            grant_type: 'refresh_token',
            refresh_token: profile.whop_refresh_token,
            redirect_uri: `${baseUrl}/api/auth/callback/whop`,
          }),
        });

        if (tokenRes.ok) {
          const tokens = await tokenRes.json();
          return NextResponse.json({ token: tokens.access_token });
        }

        const errText = await tokenRes.text();
        if (errText.includes('invalid_grant')) {
          // Clear the stale refresh token
          const { createAdminClient } = await import('@/lib/supabase/server');
          await createAdminClient()
            .from('profiles')
            .update({ whop_refresh_token: null })
            .eq('user_id', user.id);
        }

        console.warn('[chat/token] OAuth refresh failed:', errText);
      } catch (err) {
        console.warn('[chat/token] OAuth refresh threw:', err);
      }
    }
  }

  // Strategy 3: Seller company token (fallback — limited to company-scoped chat)
  const { data: seller } = await supabase
    .from('seller_accounts')
    .select('whop_company_id')
    .eq('user_id', user.id)
    .single();

  if (seller?.whop_company_id) {
    try {
      console.log('[chat/token] Falling back to company access token');
      const { token } = await whop.accessTokens.create({
        company_id: seller.whop_company_id,
      });
      return NextResponse.json({ token });
    } catch (err) {
      console.error('[chat/token] Company token failed:', err);
    }
  }

  return NextResponse.json(
    {
      error: 'Could not generate a chat token. Connect your Whop account to enable chat.',
    },
    { status: 403 }
  );
}

Also create src/app/api/token/route.ts as an alias, since Whop's embedded components expect a token endpoint at /api/token by default:

route.ts
export { GET } from '../chat/token/route';

Embedding the chat component

Create src/components/messages/WhopChatEmbed.tsx:

WhopChatEmbed.tsx
'use client';

import { useState, useEffect, useMemo } from 'react';
import { loadWhopElements } from '@whop/embedded-components-vanilla-js';
import { Elements, ChatSession, ChatElement } from '@whop/embedded-components-react-js';

interface WhopChatEmbedProps {
  channelId: string;
  onAuthRequired?: () => void;
}

export function WhopChatEmbed({ channelId, onAuthRequired }: WhopChatEmbedProps) {
  const [token, setToken] = useState<string | null>(null);
  const [isReady, setIsReady] = useState(false);
  const [authError, setAuthError] = useState(false);

  const whopEnv = (process.env.NEXT_PUBLIC_WHOP_ENVIRONMENT ?? 'production') as
    | 'sandbox'
    | 'production';

  const loader = useMemo(() => loadWhopElements, []);

  useEffect(() => {
    fetch('/api/token', { credentials: 'include' })
      .then((res) => {
        if (res.status === 401 || res.status === 403) {
          setAuthError(true);
          onAuthRequired?.();
          return null;
        }
        return res.json();
      })
      .then((data) => {
        if (data?.token) setToken(data.token);
      })
      .catch((err) => {
        console.error('[WhopChatEmbed] Token fetch failed:', err);
      });
  }, [onAuthRequired]);

  if (authError) {
    return (
      <div className="flex flex-col items-center justify-center h-48 text-center text-gray-500">
        <p className="font-medium">Connect your Whop account to use chat</p>
        <a href="/api/auth/whop/authorize" className="mt-2 text-sm underline">
          Connect Whop account
        </a>
      </div>
    );
  }

  if (!token) {
    return (
      <div className="flex items-center justify-center h-48 text-gray-400">
        Loading chat…
      </div>
    );
  }

  if (!channelId) {
    return (
      <div className="flex items-center justify-center h-48 text-gray-400">
        No chat channel available for this order yet.
      </div>
    );
  }

  return (
    <div className="relative h-full min-h-[400px]">
      {!isReady && (
        <div className="absolute inset-0 flex items-center justify-center bg-white text-gray-400">
          Loading chat…
        </div>
      )}
      <Elements loader={loader}>
        <ChatSession
          getToken={async () => token}
          environment={whopEnv}
          appearance={{ theme: 'light' }}
        >
          <ChatElement
            channelId={channelId}
            emptyState="Send a message to start the conversation"
            onReady={() => setIsReady(true)}
          />
        </ChatSession>
      </Elements>
    </div>
  );
}

If a user did not authenticate with Whop OAuth, the component shows a prompt to connect their account. If they did, they get the full Whop chat experience.

Checkpoint 11

At this point you should have:

  • src/app/api/chat/token/route.ts created with three-strategy token generation
  • src/app/api/token/route.ts created as an alias
  • src/components/messages/WhopChatEmbed.tsx created
  • NEXT_PUBLIC_WHOP_ENVIRONMENT set correctly in .env.local
  • Tested chat in sandbox mode with two Whop OAuth accounts

Step 12: Row level security policies

GigFlow's authorization follows three clear patterns. Understanding these patterns will help you apply consistent security as you add new tables and features. All of the policies in this section live in the same migration file we created in Step 2: supabase/migrations/20250311000000_initial_schema.sql.

Pattern 1: Self access

Users can only read and update their own data. This applies to profiles, seller accounts, and notifications. Add the following to supabase/migrations/20250311000000_initial_schema.sql:

20250311000000_initial_schema.sql
-- Users can read their own profile
create policy "profiles self read"
on public.profiles for select
using (user_id = public.current_user_id() or public.is_admin());

-- Users can update their own profile
create policy "profiles self update"
on public.profiles for update
using (user_id = public.current_user_id() or public.is_admin())
with check (user_id = public.current_user_id() or public.is_admin());

-- Sellers can only see their own seller account
create policy "seller self read"
on public.seller_accounts for select
using (user_id = public.current_user_id() or public.is_admin());

Pattern 2: Participant access

Buyers and sellers can access shared resources they are part of. This applies to orders, messages, deliveries, and requirements. Add the following to the same supabase/migrations/20250311000000_initial_schema.sql file:

20250311000000_initial_schema.sql
-- Both buyer and seller can read the order
create policy "orders buyer/seller read"
on public.orders for select
using (
  buyer_user_id = public.current_user_id()
  or seller_user_id = public.current_user_id()
  or public.is_admin()
);

-- Both buyer and seller can read messages on their orders
create policy "order_messages read"
on public.order_messages for select
using (
  exists (
    select 1 from public.orders o
    where o.id = order_messages.order_id
      and (
        o.buyer_user_id = public.current_user_id()
        or o.seller_user_id = public.current_user_id()
        or public.is_admin()
      )
  )
);

-- Only participants can write messages
create policy "order_messages write"
on public.order_messages for insert
with check (
  sender_user_id = public.current_user_id()
  and exists (
    select 1 from public.orders o
    where o.id = order_messages.order_id
      and (
        o.buyer_user_id = public.current_user_id()
        or o.seller_user_id = public.current_user_id()
      )
  )
);

-- Order deliveries: accessible to order participants
create policy "order_deliveries read"
on public.order_deliveries for select
using (
  exists (
    select 1 from public.orders o
    where o.id = order_deliveries.order_id
      and (
        o.buyer_user_id = public.current_user_id()
        or o.seller_user_id = public.current_user_id()
        or public.is_admin()
      )
  )
);

Pattern 3: Admin-only visibility

Sensitive operational tables are only accessible to admins. This applies to payment records and webhook events. Add the following to the same supabase/migrations/20250311000000_initial_schema.sql file:

20250311000000_initial_schema.sql
-- Payment records: admin only
create policy "whop tables admin read"
on public.whop_payments for select
using (public.is_admin());

-- Webhook events: admin only
create policy "webhooks admin read"
on public.webhook_events for select
using (public.is_admin());

Checkpoint 12

At this point you should have:

  • Confirmed that RLS is enabled on all tables in your Supabase project
  • Verified that the three RLS patterns are applied correctly
  • Tested that a buyer cannot read another buyer's orders
  • Tested that a seller cannot read another seller's orders

Step 13: Deploying to production

GigFlow is a standard Next.js application that deploys to Vercel with minimal configuration.

Get your live Whop credentials

Everything you've used so far came from sandbox.whop.com. Sandbox keys will not move real money, and they will not work against the live Whop API. Before deploying, recreate your setup on the live dashboard at Whop.com and collect a fresh set of credentials:

  1. Sign in to whop.com and create the same parent company you had in sandbox. Copy the new WHOP_PLATFORM_COMPANY_ID (starts with biz_)
  2. Go to Developer → API keys and create a new live API key. Copy the new WHOP_API_KEY
  3. Go to Developer → Your App → OAuth, create a new OAuth app with your production redirect URI (e.g., https://your-app.vercel.app/api/auth/callback/whop), and copy the new WHOP_OAUTH_CLIENT_ID and WHOP_OAUTH_CLIENT_SECRET
  4. Go to Developer → Webhooks, create a new webhook pointing to https://your-app.vercel.app/api/webhooks/whop, subscribe to the same events you enabled in sandbox, and copy the new WHOP_WEBHOOK_SECRET

Keep your sandbox keys in .env.local for ongoing development. The live keys above only go into Vercel.

Deploying to Vercel

First, push your code to a GitHub repository. Then:

  1. Go to Vercel.com and click Add New Project
  2. Import your GitHub repository
  3. In the Environment Variables section, add every variable from your .env.local file, but replace the Whop ones (WHOP_API_KEY, WHOP_PLATFORM_COMPANY_ID, WHOP_WEBHOOK_SECRET, WHOP_OAUTH_CLIENT_ID, WHOP_OAUTH_CLIENT_SECRET) with the live values you just collected
  4. Set NEXT_PUBLIC_APP_URL to your production domain (e.g., https://your-app.vercel.app)
  5. Set NEXT_PUBLIC_WHOP_ENVIRONMENT to production
  6. Set NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI to your production callback URL
  7. Click Deploy

After the first deploy succeeds, double-check that the webhook endpoint URL in the live Whop dashboard matches your production domain.

Checkpoint 13

  • All environment variables updated for production
  • NEXT_PUBLIC_WHOP_ENVIRONMENT set to production
  • NEXT_PUBLIC_APP_URL set to your production domain
  • Webhook endpoint updated in Whop dashboard to production URL
  • OAuth redirect URI updated in Whop dashboard to production URL
  • WHOP_WEBHOOK_SECRET set in production environment
  • RLS enabled on all tables and verified in Supabase dashboard
  • KYC trigger confirmed present in production database
  • Sandbox checkout tested with real test cards before switching to production
  • At least one end-to-end order completed in production before launch

Build your dream platform with the Whop Payments Network

In this guide, we used the Whop Payments Network to build a gig platform where users can sign up, become sellers, list their tiers, work with buyers, and get paid. The WPN handles payments and the KYC while Whop OAuth helps with user authentication and live chats.

Just like this Fiverr clone project, you can build any type of platform where users earn money and you get a comission like a Patreon, Gumroad, or Substack clones.

If you want to learn more about what you can build with the Whop infrastructure, check out our developer documentation.