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.
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
@themeblocks - 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
- Buyer clicks Purchase on a gig, picks a package and extras, and the app creates a Whop checkout configuration with a platform application fee
- Buyer pays through the Whop checkout embed inside the slide-out panel
- Whop fires a
payment_succeededwebhook. The app reconciles the order and stores the payment idempotently - Whop credits the seller's connected company with the payment minus your platform fee
- 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:
- Node.js v18 or later
- Git
- A Supabase account at supabase.com
- A Whop account at whop.com
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:
git clone https://github.com/kash2k6/GigFlowFiverClone.git
cd GigFlowFiverClone
Then, install the dependencies using the command:
npm install
The key packages this project depends on are:
{
"@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:
# 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:
| Variable | Purpose | Where to find it |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Your Supabase project URL | Supabase dashboard → Project Settings → API |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Public browser key for Supabase | Supabase dashboard → Project Settings → API |
SUPABASE_SERVICE_ROLE_KEY | Admin key that bypasses RLS | Supabase dashboard → Project Settings → API |
WHOP_API_KEY | Server-side Whop API key | Whop dashboard → Developer → API Keys |
WHOP_PLATFORM_COMPANY_ID | Your platform's parent company ID (biz_...) | Get it from the URL of your company dashboard |
WHOP_WEBHOOK_SECRET | Used to verify webhook signatures | Whop dashboard → Developer → Webhooks |
PLATFORM_FEE_BPS | Your take rate in basis points (1000 = 10%) | Set this yourself |
NEXT_PUBLIC_APP_URL | Absolute base URL for redirects | Your domain, or http://localhost:3000 locally |
NEXT_PUBLIC_WHOP_ENVIRONMENT | sandbox for testing, production for live | Set this yourself |
WHOP_OAUTH_CLIENT_ID | OAuth app client ID | Whop dashboard → Developer → Apps → OAuth |
WHOP_OAUTH_CLIENT_SECRET | OAuth app client secret | Whop dashboard → Developer → Apps → OAuth |
NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URI | OAuth callback URL | Must match what you registered in Whop |
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:
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:
/** 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.localfile created with all required variablessrc/lib/env.tscreated for environment validationsrc/lib/app-url.tscreated for safe redirect URL handlingNEXT_PUBLIC_WHOP_ENVIRONMENT=sandboxset 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:
npm install -g supabase
supabase login
supabase link --project-ref your-project-ref
Then apply all migrations at once:
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:
| Table | Purpose | Key columns |
|---|---|---|
profiles | User identity and public profile | user_id, email, username, display_name, role |
seller_accounts | Seller state and Whop connection | user_id, whop_company_id, kyc_status, payout_enabled |
categories | Marketplace categories | slug, name, is_active |
gigs | Gig listings | seller_user_id, title, slug, status, search_vector |
gig_packages | Tiered pricing per gig | gig_id, tier, price_cents, delivery_days, revisions_included |
gig_extras | Optional add-ons per gig | gig_id, title, price_cents, max_quantity |
orders | Purchased work contracts | gig_id, seller_user_id, buyer_user_id, status |
order_requirements | Buyer's project brief | order_id, answers, attachments, submitted_at |
order_deliveries | Seller's delivery submissions | order_id, message, items |
order_messages | In-order messaging (fallback) | order_id, sender_user_id, body |
reviews | Post-completion ratings | order_id, gig_id, rating, body |
whop_checkout_configs | Checkout config tracking | whop_checkout_config_id, order_id, application_fee_cents |
whop_payments | Payment records from Whop | whop_payment_id, order_id, status |
webhook_events | Idempotent webhook inbox | webhook_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:
-- 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:
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:
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:
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_publishtrigger 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:
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:
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:
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.tscreated with both admin and regular clientssrc/lib/supabase/middleware.tscreated with the session refresh logicsrc/middleware.tscreated 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:
'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>
);
}
Whop OAuth (recommended)
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:
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:
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.tsxcreated with email/password loginsrc/app/api/auth/whop/authorize/route.tscreated with PKCE and chat scopessrc/app/api/auth/callback/whop/route.tscreated to handle the OAuth callback- Confirmed that
WHOP_OAUTH_CLIENT_IDandWHOP_OAUTH_CLIENT_SECRETare 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:
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:
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:
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:
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:
'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.tscreatedsrc/lib/whop-verification.tscreatedsrc/app/api/sell/kyc/sync/route.tscreatedsrc/app/api/sell/payouts-token/route.tscreatedsrc/components/sell/WhopVerificationEmbed.tsxcreated- 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:
-- 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:
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:
'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:
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 });
}
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.tscreatedsrc/components/gig/OrderOptionsSlideOut.tsxcreatedsrc/app/api/checkout/confirm/route.tscreated- 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:
| From | Trigger | To |
|---|---|---|
awaiting_requirements | Buyer submits the project brief | in_progress |
in_progress | Seller submits a delivery | delivered |
delivered | Buyer accepts the delivery | completed |
delivered | Buyer requests a revision | revision_requested |
revision_requested | Seller resubmits a delivery | delivered |
in_progress / delivered | Either party opens a dispute | disputed |
disputed | Dispute resolved in buyer's favor | refunded |
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:
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:
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:
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:
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:
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.tscreatedsrc/app/api/orders/[id]/deliver/route.tscreatedsrc/app/api/orders/[id]/request-revision/route.tscreatedsrc/app/api/orders/[id]/accept-delivery/route.tscreatedsrc/app/api/orders/[id]/review/route.tscreated- 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:
| Event | What to do |
|---|---|
payment_succeeded | Upsert whop_payments, reconcile orders, notify parties |
payment_failed | Alert buyer, prevent false order creation |
payment_pending | Mark checkout as pending, poll if needed |
refund_created / refund_updated | Update order to refunded, notify parties |
dispute_created / dispute_updated | Set order to disputed, surface in admin dashboard |
dispute_alert_created | Early warning. Alert admin, consider pausing seller |
verification_succeeded | Sync seller_accounts.kyc_status = 'verified' |
payout_method_created | Mark payout method exists, unblock withdrawals |
withdrawal_created / withdrawal_updated | Update 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:
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);
}
}
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.tscreated with signature verification and idempotency- Webhook endpoint registered in the Whop dashboard
WHOP_WEBHOOK_SECRETset 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:
'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:
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.tsxcreatedsrc/app/api/sell/withdraw/route.tscreated- 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:
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:
export { GET } from '../chat/token/route';
Embedding the chat component
Create src/components/messages/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.tscreated with three-strategy token generationsrc/app/api/token/route.tscreated as an aliassrc/components/messages/WhopChatEmbed.tsxcreatedNEXT_PUBLIC_WHOP_ENVIRONMENTset 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:
-- 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:
-- 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:
-- 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:
- Sign in to whop.com and create the same parent company you had in sandbox. Copy the new
WHOP_PLATFORM_COMPANY_ID(starts withbiz_) - Go to Developer → API keys and create a new live API key. Copy the new
WHOP_API_KEY - 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 newWHOP_OAUTH_CLIENT_IDandWHOP_OAUTH_CLIENT_SECRET - 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 newWHOP_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:
- Go to Vercel.com and click Add New Project
- Import your GitHub repository
- In the Environment Variables section, add every variable from your
.env.localfile, 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 - Set
NEXT_PUBLIC_APP_URLto your production domain (e.g.,https://your-app.vercel.app) - Set
NEXT_PUBLIC_WHOP_ENVIRONMENTtoproduction - Set
NEXT_PUBLIC_WHOP_OAUTH_REDIRECT_URIto your production callback URL - 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_ENVIRONMENTset to productionNEXT_PUBLIC_APP_URLset 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_SECRETset 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.