Build a StockX clone where users can sell and bid on items using Whop Payments Network, connected accounts infrastructure, and Next.js.

Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Building a real-time marketplace like StockX involves integrating multi-party payment systems into the project, configuring an external user authentication system, setting up WebSockets for live pricing, and creating custom KYC workflows for sellers.

Fortunately for you, Whop solves the most challenging parts of these steps with a single SDK: OAuth authentication, connected accounts, escrow payments, KYC, and embedded chats.

This tutorial will guide you through building a platform from scratch based on a live bid/ask system, which we've named "Swaphause," a StockX clone.
The project has three main parts:

  • Next.js app - handles the frontend, API routes, and the matching engine
  • Supabase (PostgreSQL + Realtime) - stores all data and pushes live price updates to every connected client
  • Whop infrastructure - handles user authentication, payment processing, seller payouts, and buyer-seller chat

You can preview the finished product demo here and find the full codebase in this GitHub repository.

Project overview

Before we start coding, here's what you'll be building.

Pages

  • / - Homepage with trending products, live stats, and category browsing
  • /products - Browse all products with search, category filters, and pagination
  • /products/[id] - Product detail page with bid/ask forms, order book, price history, and size selector
  • /dashboard - User dashboard showing active bids, asks, trade history, and portfolio value
  • /trades/[id] - Trade detail page with status tracking, payment, and embedded buyer-seller chat

Core features

  • Authentication - Whop OAuth (PKCE flow) for "Sign in with Whop" - no registration forms, no password resets
  • Bid/Ask matching engine - buyers place bids, sellers place asks. When a bid meets the lowest ask, the trade executes automatically
  • Real-time pricing - Supabase Realtime pushes every new bid and ask to all connected clients instantly. No WebSocket server to manage
  • Escrow payments - Whop Payments Network handles buyer charges, seller connected accounts, KYC, and platform fee splits
  • Seller onboarding - one-click connected account creation with built-in identity verification through Whop
  • Buyer-seller chat - embedded Whop chat component creates a private DM channel for each trade automatically on match
  • Webhooks - payment events from Whop sync trade status to the database in real time
  • Notifications - in-app notification feed for trade matches, payments, and status changes
  • Search and browse - full-text search, category filtering, and custom pagination
  • Product verification - admin review flow for authenticating items before releasing seller payouts

Part 1: Architecture

The architecture setup of the StockX clone project

In this guide, we'll be building a StockX clone where buyers and sellers trade products through a bid system. During trades, buyers name their price (bid), and sellers name their (ask). When a bid meets or exceeds the lowest ask, the trade is executed.

While building this project, we're going to use several services for things like user authentication, payments, testing, and validation. Whop's OAuth and Whop Payment Network are two of the biggest players we'll use, so let's understand why we're using Whop.

Why Whop

In marketplace projects like this, you'll face two hard infrastructure problems: user authentication and money movement:

Payments

A marketplace platform like this requires the developers to integrate multiple systems that handle connected accounts, KYC compliance, escrow holds, refund processing, and other complex payment flows.

As the number of external services increases, the time spent integrating each one (and stitching them together) grows exponentially.

Luckily, the Whop Payments Network handles all of this through a single API: connected seller accounts, escrow holds, payouts, and refunds, and more.

As a cherry on the cake, you'll use Whop for easy user authentication too, allowing you to integrate a fully functional user authentication system without storing passwords or managing 2Fa yourself.

Authentication

Whop OAuth gives you the "Sign in with Whop" button that uses a standard OAuth 2.1 + PKCE flow. Users authorize your app, you get an access token, and you have a verified identity without building registration forms, email verification, or password reset flows. All authentication needs are solved with a single integration.

Other flows like bid/ask matching, real-time price feeds, product pages, search, notifications, etc. will be custom code.

How money moves

The payment flow of this project follows an escrow pattern:

  1. A bid matches an ask, the trade executes
  2. The buyer is charged via Whop (direct charge on the seller's connected account with an application fee for the platform)
  3. Funds are held, the seller hasn't been paid yet
  4. The seller ships the item to the platform for authentication
  5. The platform verifies the item is legitimate
  6. If verified: the seller's payout is released via their Whop connected account
  7. If failed: the buyer is refunded through Whop, the item is returned, and the listing can be reposted

The platform takes a percentage on every successful transaction via the application_fee_amount on the checkout configuration. The seller receives the remainder. Whop handles the fee split, the KYC, and the payout rails.

Layer Choice Why
Framework Next.js (App Router) Server components, API routes, and deployment on Vercel in one package
Auth Whop OAuth Already in the Whop ecosystem, standard PKCE flow, no auth infrastructure to build
Payments Whop Payments Network Connected accounts + escrow + KYC
Database Supabase (PostgreSQL) Managed Postgres built-in Realtime
Real-time Supabase Realtime Subscribe to database changes. When a new bid lands, every connected client sees it instantly. No WebSocket server to manage
ORM Prisma Type-safe database access, migration management, schema-as-documentation
Validation Zod Runtime validation on API routes, env variables, and webhook payloads
Deployment Vercel Zero-config Next.js hosting, vercel.ts for typed configuration

Scaffold and deploy

In this guide, we're going to follow a deploy-first workflow that gets us a live URL before we start writing marketplace code. First, let's set up three services: Next.js on Vercel, a Supabase database, and a Whop app (on Whop's sandbox):

Create the Next.js project and deploy to Vercel

To create the Next.js project, go to the directory you want to develop your project in and run the command below:

Terminal
npx create-next-app@latest stockx-clone
You can replace the “stockx-clone” part of the command with the project name you want. For the sake of simplicity, we’ll refer to the folder as “stockx-clone” in this guide.

At some point, it will ask you "Would you like to use the recommended Next.js defaults?," and you should select the "Yes, use recommended defaults" option. This will install the required packages.
Then, let's push the project into GitHub by running the commands below:

Terminal
cd stockx-clone
git init
git add .
git commit -m "Initial scaffold"
gh repo create stockx-clone --private --source=. --push

The gh repo create command creates the repo on GitHub, sets the remote, and pushes in one step. If you don't have the GitHub CLI, install it from cli.github.com and run gh auth login first.

Now, let's create a Vercel project:

  1. Go to vercel.com and sign in with your GitHub account
  2. Click Add New > Project
  3. Import your stockx-clone repository from the list
  4. Leave the default settings (Vercel auto-detects Next.js) and click Deploy
  5. The first deploy will show the default Next.js page. That's fine, we'll add environment variables next

Set up Supabase

Now, let's set up Supabase by creating an account and starting a new project. In Supabase, you'll create the project in an organization. If you don't have any, follow the steps below to create an organization:

  1. Go to the Supabase dashboard and click New organization
  2. Give your organization a name, select its type, and plan
  3. Click Create organization
    This will redirect you to the Create a new project page, where you should give your project a name, set a strong database password, select the region you want the database to be located in, and click Create new project.

Once you create the project, let's grab some values you'll use later. Click the Connect button at the top of your project dashboard - it shows your Project URL, API keys, and connection strings in one place. You can also find the API keys under Settings > API Keys:

Value Where to find it Env var name
Project URL Connect dialog or Settings > API Keys NEXT_PUBLIC_SUPABASE_URL
Anon public key Connect dialog or Settings > API Keys (labeled anon) NEXT_PUBLIC_SUPABASE_ANON_KEY
Service role key Settings > API Keys (labeled service_role) SUPABASE_SERVICE_ROLE_KEY
Connection string Connect dialog > Connection String (Session pooler) DATABASE_URL

For the connection string, replace [YOUR-PASSWORD] with the database password you set during project creation. Use the Session pooler connection string - it works with both IPv4 and IPv6 and is the recommended default.

Note: Supabase is transitioning to new-style API keys (sb_publishable_... and sb_secret_...). The legacy JWT-based anon and service_role keys still work and are what we use in this project. You'll see both key types in your dashboard - use the legacy JWT keys.

Get Whop sandbox keys

In the development phase, you're going to use Whop's sandbox environment - it allows you to simulate all flows using Whop without moving real money.

  1. Create a sandbox account at sandbox.whop.com (this is separate from a regular Whop account)
  2. Go to sandbox.whop.com/dashboard/developer and create a new app
  3. In your app settings, set the OAuth Redirect URI to http://localhost:3000/api/auth/callback
  4. Grab these values from your app dashboard:
Value Env var name
API Key WHOP_API_KEY
App ID WHOP_APP_ID
Client ID WHOP_CLIENT_ID
Client Secret WHOP_CLIENT_SECRET
Webhook Secret WHOP_WEBHOOK_SECRET
Company ID WHOP_COMPANY_ID
Important: WHOP_API_KEY must be a company API key, not an app API key. You'll find it under your company's Settings > API Keys in the Whop dashboard. The company API key has broader permissions (like creating connected accounts for sellers) that the app API key doesn't. Your WHOP_COMPANY_ID is the biz_... value from the URL when you're on your company dashboard.

You'll also need one more env var that tells the app to use sandbox:

.env
WHOP_API_BASE=https://sandbox-api.whop.com

This single variable controls whether the entire app talks to sandbox or production Whop. OAuth, SDK calls, and webhooks will use this variable. Set it to https://sandbox-api.whop.com for development and preview deployments. For production, you'll set it to https://api.whop.com later.

Configure environment variables

Now, let's configure the environment variables of the project. First, go to your Vercel dashboard, open the project, and go to its settings.

There, open the Environment Variables page and add each variable from Steps 2 and 3. Vercel lets you set different values per deployment context (Production, Preview, Development), you can use this to separate sandbox from production:

Context WHOP_API_BASE Whop keys from
Production https://api.whop.com whop.com/dashboard/developer
Preview https://sandbox-api.whop.com sandbox.whop.com/dashboard/developer
Development https://sandbox-api.whop.com sandbox.whop.com/dashboard/developer

For each context, add the matching WHOP_API_KEY, WHOP_APP_ID, WHOP_CLIENT_ID, WHOP_CLIENT_SECRET, WHOP_WEBHOOK_SECRET, and WHOP_COMPANY_ID from the corresponding Whop dashboard.

The Supabase variables (DATABASE_URL, NEXT_PUBLIC_SUPABASE_URL, etc.) are the same across all contexts unless you want a separate test database.

Next, add two app-level variables:

Env var Value
NEXT_PUBLIC_APP_URL https://your-project.vercel.app (your Vercel deployment URL)
SESSION_SECRET A random string, at least 32 characters

Generate a session secret with:

Terminal
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Pulling variables locally

Once everything is set up in Vercel, pull the variables to your local development environment. This requires the Vercel CLI, and you can install it using these commands:

Terminal
npm i -g vercel
vercel login
vercel link

Then pull the variables:

Terminal
vercel env pull .env.local

This creates a .env.local file with all your Development-context variables. The file is gitignored and never committed.

Deployment configuration

You'll use vercel.ts instead of vercel.json for deployment configuration - you get type safety and IDE autocomplete.

Create vercel.ts in your project root (next to package.json, not inside src/) with the content:

vercel.ts
interface VercelConfig {
  framework?: string;
  buildCommand?: string;
  outputDirectory?: string;
  headers?: Array<{
    source: string;
    headers: Array<{ key: string; value: string }>;
  }>;
}

const config: VercelConfig = {
  framework: "nextjs",
  buildCommand: "next build",
  outputDirectory: ".next",
  headers: [
    {
      source: "/api/(.*)",
      headers: [
        { key: "X-Content-Type-Options", value: "nosniff" },
        { key: "X-Frame-Options", value: "DENY" },
      ],
    },
  ],
};

export default config;
This sets the framework, adds security headers to all API routes, and gives you a place to add caching
rules later.

Next.js image configuration

If your app displays remote images (like placeholder images from placehold.co or user avatars), Next.js needs to know which domains are allowed. Create next.config.ts in the project root (next to package.json) with:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
	images: {
		dangerouslyAllowSVG: true,
		remotePatterns: [
			{
				protocol: "https",
				hostname: "placehold.co",
			},
		],
	},
};

export default nextConfig;

dangerouslyAllowSVG is needed because placehold returns SVG images, which Next.js image optimization rejects by default. Add any other image domains your app uses to remotePatterns.

Environment variable validation

Lastly before deploying the project, let's validate all required environment variables exist at startup.

This will crash your app at startup when you're missing environment variables, so that you don't run into issues later down the tutorial and spend time finding the cause.

Install Zod (we'll use it throughout the project for all input validation, not just env variables):

Terminal
npm install zod

Next, create the src/lib/ directories and a file in it called env.ts with the content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_API_KEY: z.string().trim().min(1),
  WHOP_APP_ID: z.string().trim().min(1),
  WHOP_CLIENT_ID: z.string().trim().min(1),
  WHOP_CLIENT_SECRET: z.string().trim().min(1),
  WHOP_WEBHOOK_SECRET: z.string().trim().min(1),
  WHOP_COMPANY_ID: z.string().trim().min(1),
  WHOP_API_BASE: z.string().trim().url().default("https://api.whop.com"),
  DATABASE_URL: z.string().trim().url(),
  NEXT_PUBLIC_SUPABASE_URL: z.string().trim().url(),
  NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().trim().min(1),
  NEXT_PUBLIC_APP_URL: z.string().trim().url(),
  SESSION_SECRET: z.string().trim().min(32),
  SUPABASE_SERVICE_ROLE_KEY: z.string().trim().min(1),
  PLATFORM_FEE_PERCENT: z.coerce.number().default(9.5),
});

export type Env = z.infer<typeof envSchema>;

let _env: Env | undefined;

export const env: Env = new Proxy({} as Env, {
  get(_, prop: string) {
    if (!_env) {
      _env = envSchema.parse(process.env);
    }
    return _env[prop as keyof Env];
  },
});

If you see a ZodError listing missing fields when running npm run dev, your .env.local is incomplete, go back to Step 4.

Deploy the project to Vercel

If you set up via the Vercel dashboard, your project is already deploying on every push to main. Just commit and push using the commands below:

Terminal
git add .
git commit -m "Add environment config"
git push

Now, when you open your Vercel deployment URL, you should see the default Next.js page. Check the build logs in the Vercel dashboard and see if any environment variables are missing, the build will fail with a ZodError listing exactly which ones. Fix those in Settings > Environment Variables and redeploy.

Once the page loads, you have a live URL connected to a real Supabase database with all environment variables in place. You're ready to write marketplace code.

Part 2: Data models and authentication

You have deployed the Next.js app and wired your environment variables. Now, you need two things before starting to work on the marketplace logic: a database schema that models how StockX actually works, and a secure way for users to sign in.

Designing the data model

The trade design model

The core logic behind StockX's data model is that every product has one canonical page, and every size of that product is its own market. There's no "create a listing" flow where five sellers each make their own page for the same sneaker.

Instead, there's one page for the Nike Dunk Low Panda, and within that page, each size (US 9, US 10, US 11) has its own bid/ask order book with its own price history.
This means we need a Product with multiple ProductSize records.

Bids and asks attach to a specific ProductSize, not to the product itself. When a bid matches an ask on the same size, we create a Trade, a single record that tracks the entire lifecycle from match to delivery (or refund).

A few non-obvious decisions:

  • Bids and asks are separate tables. You could model them as one Order table with a side column, but separate tables make the matching queries cleaner and let us add size-specific constraints to each side independently.
  • Trades reference both the bid and the ask. This creates a clear audit trail, you can always trace a completed sale back to the exact bid and ask that created it.
  • Payments are a separate table from trades. A trade can have multiple payment events (charge, refund, payout release), so we keep them in their own table linked by tradeId.
  • ProductSize caches aggregate stats. Rather than computing the lowest ask and highest bid on every page load, we store lowestAsk, highestBid, lastSalePrice, and salesCount directly on the ProductSize record and update them when the order book changes. This keeps product listing queries fast.
  • Notifications are stored in the database. This is a custom in-app notification feed - no external service. Each notification ties to a user and stores structured metadata as JSON.

First, install Prisma and the Prisma Client:

Terminal
npm install @prisma/client
npm install -D prisma

Initialize Prisma in your project. This creates the prisma/ directory with a schema.prisma file:

Terminal
npx prisma init

Now, let's create the Prisma schema by going into the /prisma folder in the project and updating the schema.prisma contents with:

schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

// Enums
enum UserRole {
  USER
  SELLER
  ADMIN
}

enum BidStatus {
  ACTIVE
  MATCHED
  CANCELLED
  EXPIRED
}

enum AskStatus {
  ACTIVE
  MATCHED
  CANCELLED
  EXPIRED
}

enum TradeStatus {
  MATCHED
  PAYMENT_PENDING
  PAID
  SHIPPED
  AUTHENTICATING
  VERIFIED
  DELIVERED
  FAILED
  REFUNDED
}

enum PaymentStatus {
  PENDING
  SUCCEEDED
  FAILED
  REFUNDED
}

enum NotificationType {
  BID_MATCHED
  ASK_MATCHED
  TRADE_COMPLETED
  ITEM_SHIPPED
  ITEM_VERIFIED
  ITEM_FAILED
  PRICE_ALERT
  SYSTEM
}

// Models
model User {
  id                 String   @id @default(cuid())
  whopId             String   @unique
  email              String
  username           String
  displayName        String?
  avatarUrl          String?
  role               UserRole @default(USER)
  whopAccessToken    String?
  whopRefreshToken   String?
  connectedAccountId String?
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  bids          Bid[]
  asks          Ask[]
  buyerTrades   Trade[]        @relation("BuyerTrades")
  sellerTrades  Trade[]        @relation("SellerTrades")
  notifications Notification[]
}

model Product {
  id          String    @id @default(cuid())
  name        String
  brand       String
  sku         String    @unique
  description String
  images      String[]
  category    String
  retailPrice Float
  releaseDate DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  sizes ProductSize[]
}

model ProductSize {
  id            String   @id @default(cuid())
  productId     String
  size          String
  lastSalePrice Float?
  lowestAsk     Float?
  highestBid    Float?
  salesCount    Int      @default(0)
  createdAt     DateTime @default(now())

  product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
  bids    Bid[]
  asks    Ask[]
  trades  Trade[]

  @@unique([productId, size])
}

model Bid {
  id            String    @id @default(cuid())
  userId        String
  productSizeId String
  price         Float
  status        BidStatus @default(ACTIVE)
  expiresAt     DateTime?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  user        User        @relation(fields: [userId], references: [id], onDelete: Cascade)
  productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
  trade       Trade?

  @@index([productSizeId, status])
}

model Ask {
  id            String    @id @default(cuid())
  userId        String
  productSizeId String
  price         Float
  status        AskStatus @default(ACTIVE)
  expiresAt     DateTime?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  user        User        @relation(fields: [userId], references: [id], onDelete: Cascade)
  productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
  trade       Trade?

  @@index([productSizeId, status])
}

model Trade {
  id            String      @id @default(cuid())
  buyerId       String
  sellerId      String
  productSizeId String
  bidId         String      @unique
  askId         String      @unique
  price         Float
  platformFee   Float
  chatChannelId String?
  status        TradeStatus @default(MATCHED)
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt

  buyer       User        @relation("BuyerTrades", fields: [buyerId], references: [id], onDelete: Cascade)
  seller      User        @relation("SellerTrades", fields: [sellerId], references: [id], onDelete: Cascade)
  productSize ProductSize @relation(fields: [productSizeId], references: [id], onDelete: Cascade)
  bid         Bid         @relation(fields: [bidId], references: [id], onDelete: Cascade)
  ask         Ask         @relation(fields: [askId], references: [id], onDelete: Cascade)
  payment     Payment?

  @@index([buyerId])
  @@index([sellerId])
}

model Payment {
  id              String        @id @default(cuid())
  tradeId         String        @unique
  whopPaymentId   String        @unique
  amount          Float
  platformFee     Float
  status          PaymentStatus @default(PENDING)
  idempotencyKey  String        @unique
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

  trade Trade @relation(fields: [tradeId], references: [id], onDelete: Cascade)
}

model Notification {
  id        String           @id @default(cuid())
  userId    String
  type      NotificationType
  title     String
  message   String
  read      Boolean          @default(false)
  metadata  Json?
  createdAt DateTime         @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, read])
}

After updating the Prisma schema, push it to your Supabase database. But first, Prisma CLI reads DATABASE_URL from .env, not .env.local. Since Vercel env pull wrote everything to .env.local, you need a separate .env file for Prisma:

Terminal
echo 'DATABASE_URL="your-supabase-connection-string"' > .env

Copy the DATABASE_URL value from your .env.local file. This .env is already covered by .gitignore. To prevent it from being uploaded during Vercel deploy, create a .vercelignore file in the project root with the content:

.vercelignore
.env
.env.local

Now push the schema using the command below:

Terminal
npx prisma db push

Enable Realtime

Before moving on, let's go to Supabase and enable Realtime for the Bid and Ask tables. In Supabase, in the Database page of your project, go to Replication, and toggle Realtime on for both tables.

This tells Supabase to broadcast row changes (inserts, updates, deletes) over its Realtime channels. We'll subscribe to these changes on the frontend in Part 3. Without this, the client-side subscriptions we build later will silently receive nothing.

Whop OAuth

Users sign in with their Whop account via OAuth 2.1. Before writing code, create an app in the Whop developer dashboard.

For development, use the sandbox dashboard at sandbox.whop.com/dashboard/developer, this gives you test credentials that won't process real payments. For production, use the regular whop.com dashboard. You'll need:

  • A Client ID and Client Secret (these should already be in your Vercel env vars from Part 1 - sandbox keys for dev, production keys for prod)
  • A Redirect URI set to https://your-domain.com/api/auth/callback (or http://localhost:3000/api/auth/callback for local development)
    Notice that the OAuth routes below use env.WHOP_API_BASE instead of hardcoding https://api.whop.com. This env var switches the entire app between sandbox (https://sandbox-api.whop.com) and production (https://api.whop.com), allowing you to easily switch between Whop environments without having to edit all routes individually.

The flow works in two steps: a login route that redirects the user to Whop, and a callback route that exchanges the authorization code for tokens and creates the session.

Before writing the routes, you need a Prisma client singleton and the session management dependency. Install iron-session using the command below:

Terminal
npm install iron-session

Now, let's create the Prisma client singleton. This file reuses one PrismaClient instance across hot reloads in development. It also appends connection_limit=1 to the database URL.

Without this, each Vercel serverless function opens its own connection pool, which quickly exhausts Supabase's session pooler limit and causes 500 errors. Go to src/lib and create a file called prisma.ts with the content:

prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
	prisma: PrismaClient | undefined;
};

function buildDatasourceUrl(): string {
	const url = process.env.DATABASE_URL!;
	if (url.includes("connection_limit")) return url;
	const separator = url.includes("?") ? "&" : "?";
	return `${url}${separator}connection_limit=1`;
}

export const prisma =
	globalForPrisma.prisma ??
	new PrismaClient({
		datasources: { db: { url: buildDatasourceUrl() } },
});

if (process.env.NODE_ENV !== "production") {
	globalForPrisma.prisma = prisma;
}

Time to create the auth routes. In Next.js App Router, each API route lives in its own folder with a route.ts file. Let's go to the src/app/api/auth folder and create two folders called login and callback.

Then, open the src/app/api/auth/login folder and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { env } from "@/lib/env";

function base64url(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = "";
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function GET() {
  const codeVerifierBytes = new Uint8Array(32);
  crypto.getRandomValues(codeVerifierBytes);
  const codeVerifier = base64url(codeVerifierBytes.buffer);

  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(codeVerifier)
  );
  const codeChallenge = base64url(digest);

  const stateBytes = new Uint8Array(16);
  crypto.getRandomValues(stateBytes);
  const state = Array.from(stateBytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  const nonceBytes = new Uint8Array(16);
  crypto.getRandomValues(nonceBytes);
  const nonce = Array.from(nonceBytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;

  const authUrl = new URL(`${env.WHOP_API_BASE}/oauth/authorize`);
  authUrl.searchParams.set("client_id", env.WHOP_CLIENT_ID);
  authUrl.searchParams.set("redirect_uri", redirectUri);
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
  authUrl.searchParams.set("scope", "openid profile email");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("nonce", nonce);

  const cookieValue = JSON.stringify({ codeVerifier, state });

  const response = NextResponse.redirect(authUrl.toString());
  response.cookies.set("oauth_pkce", cookieValue, {
    httpOnly: true,
    secure: env.NEXT_PUBLIC_APP_URL.startsWith("https"),
    sameSite: "lax",
    path: "/",
    maxAge: 600, // 10 minutes
  });

  return response;
}

When the user authorizes a Whop login, they get redirected them back to your callback route with an authorization code.

Now, you need a callback route that handles the redirect from Whop by exchanging the authorization code for tokens, fetching the user's profile, and creating or updating their account in the database.

Note that it imports sessionOptions from @/lib/auth, which we'll create next.
To create the callback route, go to src/app/api/auth/callback and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { type SessionData, sessionOptions } from "@/lib/auth";
import { env } from "@/lib/env";

interface TokenResponse {
  access_token: string;
  refresh_token: string;
  token_type: string;
  expires_in: number;
}

interface UserInfoResponse {
  sub: string;
  email?: string;
  email_verified?: boolean;
  preferred_username?: string;
  name?: string;
  picture?: string;
}

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

  if (!code || !state) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=missing_params`
    );
  }

  const pkceCookie = request.cookies.get("oauth_pkce");
  if (!pkceCookie?.value) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=missing_pkce`
    );
  }

  let storedState: string;
  let codeVerifier: string;
  try {
    const parsed = JSON.parse(pkceCookie.value) as {
      state: string;
      codeVerifier: string;
    };
    storedState = parsed.state;
    codeVerifier = parsed.codeVerifier;
  } catch {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=invalid_pkce`
    );
  }

  if (state !== storedState) {
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=state_mismatch`
    );
  }

  const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;

  const tokenRes = await fetch(`${env.WHOP_API_BASE}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      redirect_uri: redirectUri,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    const errBody = await tokenRes.text().catch(() => "no body");
    console.error("Token exchange failed:", tokenRes.status, errBody);
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=token_exchange_failed&status=${tokenRes.status}&detail=${encodeURIComponent(errBody.slice(0, 200))}`
    );
  }

  const tokenData = (await tokenRes.json()) as TokenResponse;

  const userInfoRes = await fetch(`${env.WHOP_API_BASE}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokenData.access_token}` },
  });

  if (!userInfoRes.ok) {
    const errBody = await userInfoRes.text().catch(() => "no body");
    console.error("Userinfo failed:", userInfoRes.status, errBody);
    return NextResponse.redirect(
      `${env.NEXT_PUBLIC_APP_URL}/?error=userinfo_failed&status=${userInfoRes.status}&detail=${encodeURIComponent(errBody.slice(0, 200))}`
    );
  }

  const userInfo = (await userInfoRes.json()) as UserInfoResponse;

  const user = await prisma.user.upsert({
    where: { whopId: userInfo.sub },
    update: {
      email: userInfo.email ?? undefined,
      username: userInfo.preferred_username ?? undefined,
      displayName: userInfo.name ?? undefined,
      avatarUrl: userInfo.picture ?? undefined,
      whopAccessToken: tokenData.access_token,
      whopRefreshToken: tokenData.refresh_token,
    },
    create: {
      whopId: userInfo.sub,
      email: userInfo.email ?? "",
      username: userInfo.preferred_username ?? userInfo.sub,
      displayName: userInfo.name,
      avatarUrl: userInfo.picture,
      whopAccessToken: tokenData.access_token,
      whopRefreshToken: tokenData.refresh_token,
    },
  });

  const cookieStore = await cookies();
  const session = await getIronSession<SessionData>(
    cookieStore,
    sessionOptions
  );
  session.userId = user.id;
  session.whopId = user.whopId;
  session.accessToken = tokenData.access_token;
  await session.save();

  const response = NextResponse.redirect(env.NEXT_PUBLIC_APP_URL);
  response.cookies.delete("oauth_pkce");
  return response;
}

Session Management

We use iron-session for stateless and encrypted sessions stored in an httpOnly cookie named stockx_session. The session holds the user's internal userId, their whopId, and their access token for making API calls on their behalf.

Access tokens expire after one hour and when a token refresh is needed, we use the stored refresh token from the database to get a new pair.

You imported sessionOptions in the callback route, so let's actually create the auth file. Go src/lib and create a file called auth.ts with the content:

auth.ts
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import type { User } from "@prisma/client";

export interface SessionData {
  userId: string;
  whopId: string;
  accessToken: string;
}

export const sessionOptions: SessionOptions = {
  cookieName: "stockx_session",
  password: process.env.SESSION_SECRET!,
  cookieOptions: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax" as const,
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
};

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession<SessionData>(cookieStore, sessionOptions);
}

export async function getCurrentUser(): Promise<User | null> {
  const session = await getSession();

  if (!session.userId) {
    return null;
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
  });

  return user;
}

export async function requireAuth(): Promise<User> {
  const user = await getCurrentUser();

  if (!user) {
    throw new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }

  return user;
}

Every API route and server component that needs the current user calls getCurrentUser(). Routes that require authentication use requireAuth(), which throws a 401 if no session exists. You use a single file and a single place to add token refresh logic later, and one place to check if your code breaks.

Part 3: Bid/ask engine and real-time pricing

So far, you've deployed the Next.js app to Vercel, set up a Supabase database, and built the Whop OAuth so users can sign in. Now, it's time to build one of the main parts of your project, the bid/ask engine.

Every product in the project has its own order book (open bids with buy orders and open asks with sell orders). When a bid meets or exceeds the lowest ask, it executes at the bid price.

API routes for bids and asks

The bid and ask API routes follow the same pattern. Let's break them down:
POST /api/bids - Place a new bid:

  • Authenticate the request via requireAuth()
  • Validate the request body with a Zod schema: productSizeId (string), price (positive number), expiresAt (ISO date string, optional, must be in the future)
  • Confirm the ProductSize exists
  • Create the Bid record with status ACTIVE
  • Run the matching engine - if a match is found, the engine creates the trade within a transaction
  • Return the created bid (and the trade, if matched)
  • Touches: Bid, Ask, Trade, ProductSize
    POST /api/asks - Place a new ask:
  • Same pattern as bids, but for the sell side
  • Touches: Ask, Bid, Trade, ProductSize
    Both routes require a valid session (no anonymous bids), validate prices with Zod against a min/max range defined in /src/constants/index.ts, and apply a simple in-memory rate limiter for every user ID to prevent spams. Bids and asks can include an expires, optionally.

At timestamp, a cron job marks expired entries in intervals, but the matching engine also checks expiration at match time so an expired bid never matches even if the cron hasn't run yet.

Checkout redirect on match

When the bid API returns { matched: true, trade: { id } }, the BidForm component doesn't just clear the form - it immediately redirects the buyer to Whop checkout.

The component calls POST /api/trades/{tradeId}/checkout to get a checkoutUrl, then navigates to it with window.location.href. This applies to both "Place Bid" (when the bid price meets or exceeds an existing ask) and "Buy Now" (which places a bid at the lowest ask price, guaranteeing an immediate match). The checkout flow is covered in part four.

Shared Constants

Before building the matching engine, let's create a shared constants file. It will define the platform fee, price limits, product categories, and pagination defaults used across the entire project.

First, go to src/constants (create the folder if you don't have it) and create a file in it called index.ts with the content:

index.ts
export const PLATFORM_FEE_PERCENT = 9.5;

export const MIN_BID_PRICE = 1;

export const MAX_BID_PRICE = 100_000;

export const BID_EXPIRY_DAYS = 30;

export const CATEGORIES = [
  "Sneakers",
  "Streetwear",
  "Electronics",
  "Collectibles",
  "Accessories",
  "Trading Cards",
] as const;

export type Category = (typeof CATEGORIES)[number];

export const ORDER_STATUSES: Record<string, string> = {
  MATCHED: "Matched",
  PAYMENT_PENDING: "Payment Pending",
  PAID: "Paid",
  SHIPPED: "Shipped",
  AUTHENTICATING: "Authenticating",
  VERIFIED: "Verified",
  DELIVERED: "Delivered",
  FAILED: "Authentication Failed",
  REFUNDED: "Refunded",
};

export const ITEMS_PER_PAGE = 24;

The PLATFORM_FEE_PERCENT at 9.5% is the platform's cut of every trade made in the app. You can change this rate to your liking. The matching engine, checkout configuration, and trade records all reference this constant.

Building the matching engine

The matching engine is the core of the marketplace project you're working on. When a new bid or ask is created by users, the matching engine checks if it can be immediately matched across the order book.

If it can, it will create a Trade and update both the bid and the ask statuses, calculate the platform fee, update the cached stats on ProductSize, and sends notifications to both users.

The engine has a double-checking pattern. It first searches outside the transaction to find a potential match, then re-fetches both records inside the transaction to confirm they're still ACTIVE before proceeding. This prevents conditions where two concurrent requests try to match against the same bid or ask.

Now, let's go to the src/lib folder and create a file called matching-engine.ts with the content:

matching-engine.ts
import { BidStatus, AskStatus, TradeStatus, NotificationType } from "@prisma/client";
import type { Prisma, Trade } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { PLATFORM_FEE_PERCENT } from "@/constants";
import { createDmChannel, sendSystemMessage } from "@/services/chat";

type TransactionClient = Prisma.TransactionClient;

export async function matchBid(bidId: string) {
  const bid = await prisma.bid.findUnique({
    where: { id: bidId },
  });

  if (!bid || bid.status !== BidStatus.ACTIVE) {
    return null;
  }

  const lowestAsk = await prisma.ask.findFirst({
    where: {
      productSizeId: bid.productSizeId,
      status: AskStatus.ACTIVE,
      price: { lte: bid.price },
    },
    orderBy: { price: "asc" },
  });

  if (!lowestAsk) {
    return null;
  }

  const trade = await prisma.$transaction(async (tx: TransactionClient) => {
    const freshBid = await tx.bid.findUnique({ where: { id: bidId } });
    const freshAsk = await tx.ask.findUnique({ where: { id: lowestAsk.id } });

    if (
      !freshBid ||
      freshBid.status !== BidStatus.ACTIVE ||
      !freshAsk ||
      freshAsk.status !== AskStatus.ACTIVE
    ) {
      return null;
    }

    const tradePrice = freshAsk.price;
    const platformFee = Number(
      (tradePrice * (PLATFORM_FEE_PERCENT / 100)).toFixed(2)
    );

    await tx.bid.update({
      where: { id: freshBid.id },
      data: { status: BidStatus.MATCHED },
    });

    await tx.ask.update({
      where: { id: freshAsk.id },
      data: { status: AskStatus.MATCHED },
    });

    const newTrade = await tx.trade.create({
      data: {
        buyerId: freshBid.userId,
        sellerId: freshAsk.userId,
        productSizeId: freshBid.productSizeId,
        bidId: freshBid.id,
        askId: freshAsk.id,
        price: tradePrice,
        platformFee,
        status: TradeStatus.MATCHED,
      },
    });

    await updateProductSizeStats(freshBid.productSizeId, tx);

    await tx.notification.createMany({
      data: [
        {
          userId: freshBid.userId,
          type: NotificationType.BID_MATCHED,
          title: "Bid matched!",
          message: `Your bid of $${freshBid.price.toFixed(2)} was matched at $${tradePrice.toFixed(2)}.`,
          metadata: { tradeId: newTrade.id },
        },
        {
          userId: freshAsk.userId,
          type: NotificationType.ASK_MATCHED,
          title: "Ask matched!",
          message: `Your ask of $${freshAsk.price.toFixed(2)} was matched. Prepare to ship your item.`,
          metadata: { tradeId: newTrade.id },
        },
      ],
    });

    return newTrade;
  });

  if (trade) {
    await setupTradeChat(trade);
  }

  return trade;
}

export async function matchAsk(askId: string) {
  const ask = await prisma.ask.findUnique({
    where: { id: askId },
  });

  if (!ask || ask.status !== AskStatus.ACTIVE) {
    return null;
  }

  const highestBid = await prisma.bid.findFirst({
    where: {
      productSizeId: ask.productSizeId,
      status: BidStatus.ACTIVE,
      price: { gte: ask.price },
    },
    orderBy: { price: "desc" },
  });

  if (!highestBid) {
    return null;
  }

  const trade = await prisma.$transaction(async (tx: TransactionClient) => {
    const freshAsk = await tx.ask.findUnique({ where: { id: askId } });
    const freshBid = await tx.bid.findUnique({ where: { id: highestBid.id } });

    if (
      !freshAsk ||
      freshAsk.status !== AskStatus.ACTIVE ||
      !freshBid ||
      freshBid.status !== BidStatus.ACTIVE
    ) {
      return null;
    }

    const tradePrice = freshAsk.price;
    const platformFee = Number(
      (tradePrice * (PLATFORM_FEE_PERCENT / 100)).toFixed(2)
    );

    await tx.bid.update({
      where: { id: freshBid.id },
      data: { status: BidStatus.MATCHED },
    });

    await tx.ask.update({
      where: { id: freshAsk.id },
      data: { status: AskStatus.MATCHED },
    });

    const newTrade = await tx.trade.create({
      data: {
        buyerId: freshBid.userId,
        sellerId: freshAsk.userId,
        productSizeId: freshAsk.productSizeId,
        bidId: freshBid.id,
        askId: freshAsk.id,
        price: tradePrice,
        platformFee,
        status: TradeStatus.MATCHED,
      },
    });

    await updateProductSizeStats(freshAsk.productSizeId, tx);

    await tx.notification.createMany({
      data: [
        {
          userId: freshBid.userId,
          type: NotificationType.BID_MATCHED,
          title: "Bid matched!",
          message: `Your bid of $${freshBid.price.toFixed(2)} was matched at $${tradePrice.toFixed(2)}.`,
          metadata: { tradeId: newTrade.id },
        },
        {
          userId: freshAsk.userId,
          type: NotificationType.ASK_MATCHED,
          title: "Ask matched!",
          message: `Your ask of $${freshAsk.price.toFixed(2)} was matched. Prepare to ship your item.`,
          metadata: { tradeId: newTrade.id },
        },
      ],
    });

    return newTrade;
  });

  if (trade) {
    await setupTradeChat(trade);
  }

  return trade;
}

async function setupTradeChat(trade: Trade) {
  try {
    const [buyer, seller, productSize] = await Promise.all([
      prisma.user.findUnique({
        where: { id: trade.buyerId },
        select: { whopId: true },
      }),
      prisma.user.findUnique({
        where: { id: trade.sellerId },
        select: { whopId: true },
      }),
      prisma.productSize.findUnique({
        where: { id: trade.productSizeId },
        include: { product: { select: { name: true } } },
      }),
    ]);

    if (!buyer?.whopId || !seller?.whopId || !productSize) return;
    if (buyer.whopId === seller.whopId) {
      console.log("setupTradeChat: skipping DM for self-match trade");
      return;
    }

    const channelName = `Trade: ${productSize.product.name} Size ${productSize.size}`;
    const channelId = await createDmChannel(
      buyer.whopId,
      seller.whopId,
      channelName
    );

    await prisma.trade.update({
      where: { id: trade.id },
      data: { chatChannelId: channelId },
    });

    await sendSystemMessage(
      channelId,
      `Trade matched at $${trade.price.toFixed(2)}! Use this chat to coordinate shipping details.`
    );
  } catch (error: unknown) {
    console.error("Failed to set up trade chat:", error);
  }
}

async function updateProductSizeStats(
  productSizeId: string,
  tx: TransactionClient
) {
  const lowestActiveAsk = await tx.ask.findFirst({
    where: { productSizeId, status: AskStatus.ACTIVE },
    orderBy: { price: "asc" },
    select: { price: true },
  });

  const highestActiveBid = await tx.bid.findFirst({
    where: { productSizeId, status: BidStatus.ACTIVE },
    orderBy: { price: "desc" },
    select: { price: true },
  });

  const lastTrade = await tx.trade.findFirst({
    where: { productSizeId, status: TradeStatus.DELIVERED },
    orderBy: { createdAt: "desc" },
    select: { price: true },
  });

  await tx.productSize.update({
    where: { id: productSizeId },
    data: {
      lowestAsk: lowestActiveAsk?.price ?? null,
      highestBid: highestActiveBid?.price ?? null,
      lastSalePrice: lastTrade?.price ?? undefined,
    },
  });
}

Real-time order book updates

In Part 2 you enabled Realtime on the Bid and Ask tables on Supabase. Now, when the matching engine inserts a new bid or updates an ask's status to MATCHED, Supabase sends that change to every client that's subscribed to that table.

The frontend listens for any changes to bids or asks on selected sizes of items. New bid, matched ask, cancellations, and other actions update the order book in real-time.
To wire this up, you need two things: a Supabase client that runs in the browser, and a React hook that subsribes the changes for selected item sizes.
First, let's install the Supabase client library using the command below:

Terminal
npm install @supabase/supabase-js

Then, go to src/services (create the folder if you don't have it) and create a file called supabase.ts with the content:

supabase.ts
import { createClient, type SupabaseClient } from "@supabase/supabase-js";

let browserClient: SupabaseClient | null = null;

export function createBrowserClient(): SupabaseClient {
  if (browserClient) return browserClient;

  browserClient = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );

  return browserClient;
}

export function createServerClient(): SupabaseClient {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
  );
}

The browser client is a single instance that's reused since it runs in the browser with the anonymous key. The server client creates a new instance each time since it uses the service role key for access.

The useRealtimeBids hook

Now, let's create the hook that ties the Realtime subscription to React. Go to src/hooks and create a file called useRealtimeBids.ts with the content:

useRealtimeBids.ts
"use client";

import { useEffect, useState, useCallback } from "react";
import { createBrowserClient } from "@/services/supabase";

interface Bid {
  id: string;
  userId: string;
  productSizeId: string;
  price: number;
  status: string;
  expiresAt: string | null;
  createdAt: string;
}

interface Ask {
  id: string;
  userId: string;
  productSizeId: string;
  price: number;
  status: string;
  expiresAt: string | null;
  createdAt: string;
}

interface UseRealtimeBidsReturn {
  bids: Bid[];
  asks: Ask[];
  isLoading: boolean;
  error: string | null;
}

export function useRealtimeBids(productSizeId: string): UseRealtimeBidsReturn {
  const [bids, setBids] = useState<Bid[]>([]);
  const [asks, setAsks] = useState<Ask[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchBids = useCallback(async () => {
    try {
      const res = await fetch(
        `/api/bids?productSizeId=${encodeURIComponent(productSizeId)}`
      );
      if (!res.ok) throw new Error("Failed to fetch bids");
      const data = await res.json();
      setBids(
        (data.bids ?? data ?? []).sort(
          (a: Bid, b: Bid) => b.price - a.price
        )
      );
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch bids");
    }
  }, [productSizeId]);

  const fetchAsks = useCallback(async () => {
    try {
      const res = await fetch(
        `/api/asks?productSizeId=${encodeURIComponent(productSizeId)}`
      );
      if (!res.ok) throw new Error("Failed to fetch asks");
      const data = await res.json();
      setAsks(
        (data.asks ?? data ?? []).sort(
          (a: Ask, b: Ask) => a.price - b.price
        )
      );
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch asks");
    }
  }, [productSizeId]);

  useEffect(() => {
    setIsLoading(true);
    setError(null);

    Promise.all([fetchBids(), fetchAsks()]).finally(() => setIsLoading(false));

    const supabase = createBrowserClient();

    const channel = supabase
      .channel(`orderbook-${productSizeId}`)
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: "Bid",
          filter: `productSizeId=eq.${productSizeId}`,
        },
        () => {
          fetchBids();
        }
      )
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: "Ask",
          filter: `productSizeId=eq.${productSizeId}`,
        },
        () => {
          fetchAsks();
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [productSizeId, fetchBids, fetchAsks]);

  return { bids, asks, isLoading, error };
}

The hook loads all active bids and asks for the selected size when the element first renders in the user's end. After that, it listens to changes like new bids, matching asks, or order cancellations.

Bids are sorted highest-first, asks lowest-first. When the user navigates away, the connection is cleaned up automatically.

At this point you should have:

  • Constants file at /src/constants/index.ts with platform fee, price boundaries, categories, and pagination defaults
  • Bid and ask API routes with Zod validation, auth checks, and rate limiting
  • Matching engine in /src/lib/matching-engine.ts that atomically pairs bids with asks
  • Double-check pattern preventing race conditions on concurrent matches
  • Platform fee calculated and stored on every trade
  • ProductSize stats updated automatically after each match
  • Notifications created within the matching transaction
  • Buy Now and Sell Now working through the same matching logic
  • BidForm auto-redirects to Whop checkout when a bid matches immediately (calls POST /api/trades/{id}/checkout and navigates to the checkoutUrl)
  • Supabase client at /src/services/supabase.ts with browser singleton and server factory
  • Supabase Realtime broadcasting bid/ask table changes
  • useRealtimeBids hook at /src/hooks/useRealtimeBids.ts providing live order book data to the frontend
  • A product page where the bid/ask spread updates in real time

Part 4: Payments, escrow, and webhooks

Trade steps

We talked about how the money flows in the project in part one, now, let's build it. By the end of this section, you'll have:

  • Buyer charges
  • Seller payouts
  • Escrow hold pattern
  • Webhook handlers

All of these flows will use Whop Payments Network.

Why use Whop Payments Network?

A marketplace project like this with multiple sellers needs connected accounts. Each seller gets their own identity with the payments service, and their payouts. Your app sits in the middle, takes a platform cut, and orchestrates the flow.

Using Whop Payments Network for payments services keeps everything in one ecosystem, plus you use it for user authentication as well.

When it comes to charges, you're going to use direct charges - the charge is created on seller's account, making it the merchant of record. The platform specifies an application_fee_amount that gets routes to your project automatically.

Seller onboarding

Before your sellers can start receiving payouts, they need a connected account with a completed KYC and a payout method file. The onboarding flow you'll use in the project follows these steps:

  1. The seller signs up on your platform via Whop OAuth
  2. When they navigate to "Start selling," the platform creates a connected account for them via the Whop API
  3. The seller is redirected to Whop-hosted onboarding where they verify their identity, provide business information, and add a bank account or other payout method
  4. Whop sends a callback/webhook when onboarding completes
  5. The platform stores the seller's connected account status and company_id
    Requirements for the seller onboarding flow you're going to build are:
    1. Track connected account status per user (PENDING, ACTIVE, SUSPENDED)
    2. Store the seller's Whop company_id (you'll need this for every charge)
    3. Handle the KYC completion callback and update the seller's status
    4. Gate all selling actions (creating asks) behind ACTIVE connected account status
    5. Show onboarding progress clearly to the seller

The seller onboarding API route (POST /api/sellers/onboard) creates a child company under your parent company via whopsdk.companies.create(), passing env.WHOP_COMPANY_ID as the parent_company_id. This env var is the biz_... value from your company dashboard URL.

The WHOP_API_KEY must be a company API key (found in company Settings > API Keys), not an app API key - only company keys have the company:create_child permission needed for creating connected accounts.

Gating the sell UI

To enforce the onboarding requirement in the frontend, create a useCurrentUser hook at src/hooks/useCurrentUser.ts that fetches the current user from /api/auth/me (which already returns role and connectedAccountId):

useCurrentUser.ts
"use client";

import { useState, useEffect } from "react";

interface CurrentUser {
  id: string;
  username: string;
  displayName: string | null;
  avatarUrl: string | null;
  role: string;
  connectedAccountId: string | null;
}

export function useCurrentUser() {
  const [user, setUser] = useState<CurrentUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch("/api/auth/me")
      .then((res) => (res.ok ? res.json() : null))
      .then((data) => {
        if (data?.user) setUser(data.user);
      })
      .catch(() => {})
      .finally(() => setIsLoading(false));
  }, []);

  return { user, isLoading };
}

Use this hook in two places:

  1. AskForm component, before rendering the ask form, check user.connectedAccountId. If the user isn't logged in, show a "Sign in to sell" prompt. If they're logged in but haven't onboarded, show a "Become a Seller" button that calls POST /api/sellers/onboard and redirects to the Whop KYC page.
  2. Dashboard Selling tab, same check. If the user hasn't completed seller onboarding, show an onboarding prompt instead of the active asks table.
    This way, the onboarding flow is surfaced everywhere a user tries to sell - they're never left wondering why they can't place an ask.

The payment orchestration lives in two service files. First, let's go to src/services and create a file called whop.ts with the content:

whop.ts
import { whopsdk } from "@/lib/whop";
import { env } from "@/lib/env";

interface TradeForCheckout {
  id: string;
  price: number;
  platformFee: number;
  buyerId: string;
  sellerId: string;
  seller: {
    whopId: string;
    connectedAccountId?: string | null;
  };
}

interface CheckoutResult {
  checkoutUrl: string;
  checkoutId: string;
}

export async function createCheckoutForTrade(
  trade: TradeForCheckout
): Promise<CheckoutResult> {
  if (!trade.seller.connectedAccountId) {
    throw new Error("Seller does not have a connected Whop account");
  }

  const checkoutConfig = await whopsdk.checkoutConfigurations.create({
    redirect_url: `${env.NEXT_PUBLIC_APP_URL}/api/trades/${trade.id}/payment-callback`,
    plan: {
      company_id: trade.seller.connectedAccountId,
      currency: "usd",
      initial_price: trade.price,
      plan_type: "one_time",
      application_fee_amount: trade.platformFee,
    },
    metadata: {
      tradeId: trade.id,
      buyerId: trade.buyerId,
      sellerId: trade.sellerId,
    },
  });

  if (!checkoutConfig || !checkoutConfig.id) {
    throw new Error("Failed to create checkout session");
  }

  return {
    checkoutUrl: checkoutConfig.purchase_url as string,
    checkoutId: checkoutConfig.id,
  };
}

export async function getPaymentStatus(paymentId: string) {
  const payment = await whopsdk.payments.retrieve(paymentId);
  return payment;
}

export async function refundPayment(paymentId: string) {
  const refund = await whopsdk.payments.refund(paymentId);
  return refund;
}

export async function createTransfer(
  amount: number,
  originCompanyId: string,
  destinationCompanyId: string,
  metadata: Record<string, string>
) {
  const transfer = await whopsdk.transfers.create({
    amount,
    currency: "usd",
    origin_id: originCompanyId,
    destination_id: destinationCompanyId,
    metadata,
  });

  return transfer;
}

Now create the payment orchestration service that uses these wrappers. Go to src/services and create a file called payments.ts with the content:

payments.ts
import { TradeStatus, PaymentStatus, NotificationType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { createCheckoutForTrade, refundPayment } from "@/services/whop";

export async function initiatePayment(tradeId: string) {
  const trade = await prisma.trade.findUnique({
    where: { id: tradeId },
    include: {
      seller: true,
      productSize: { include: { product: true } },
    },
  });

  if (!trade) {
    throw new Error("Trade not found");
  }

  if (trade.status !== TradeStatus.MATCHED) {
    throw new Error(`Trade is in ${trade.status} state, expected MATCHED`);
  }

  const checkout = await createCheckoutForTrade({
    id: trade.id,
    price: trade.price,
    platformFee: trade.platformFee,
    buyerId: trade.buyerId,
    sellerId: trade.sellerId,
    seller: {
      whopId: trade.seller.whopId,
      connectedAccountId: trade.seller.connectedAccountId,
    },
  });

  await prisma.trade.update({
    where: { id: trade.id },
    data: { status: TradeStatus.PAYMENT_PENDING },
  });

  return checkout;
}

export async function processRefund(tradeId: string) {
  const trade = await prisma.trade.findUnique({
    where: { id: tradeId },
    include: { payment: true, ask: true, productSize: { include: { product: true } } },
  });

  if (!trade || !trade.payment) {
    throw new Error("Trade or payment not found");
  }

  if (trade.status !== TradeStatus.FAILED) {
    throw new Error(`Trade is in ${trade.status} state, expected FAILED`);
  }

  await refundPayment(trade.payment.whopPaymentId);

  await prisma.$transaction(async (tx) => {
    await tx.payment.update({
      where: { id: trade.payment!.id },
      data: { status: PaymentStatus.REFUNDED },
    });

    await tx.trade.update({
      where: { id: trade.id },
      data: { status: TradeStatus.REFUNDED },
    });

    if (trade.ask) {
      await tx.ask.update({
        where: { id: trade.ask.id },
        data: { status: "ACTIVE" },
      });
    }

    await tx.notification.createMany({
      data: [
        {
          userId: trade.buyerId,
          type: NotificationType.ITEM_FAILED,
          title: "Refund processed",
          message: `Your payment of $${trade.price.toFixed(2)} for ${trade.productSize.product.name} has been refunded.`,
          metadata: { tradeId: trade.id },
        },
        {
          userId: trade.sellerId,
          type: NotificationType.ITEM_FAILED,
          title: "Item relisted",
          message: `Your ask for ${trade.productSize.product.name} has been relisted after authentication failure.`,
          metadata: { tradeId: trade.id },
        },
      ],
    });
  });
}

The initiatePayment function creates the Whop checkout on the seller's connected account and transitions the trade to PAYMENT_PENDING. processRefund handles the reverse - refunding the buyer, reopening the seller's ask, and notifying both parties.
For managing these flows, you're going to need a dashboard, and we're going to cover that in part six.

Payment on match

When your matching engine matches a bid and an ask, it creates a trade and the payment flow gets activated - following the steps below:

  1. The matching engine creates a Trade record with status MATCHED
  2. The platform creates a checkout configuration on the seller's connected account using client.checkoutConfigurations.create(), specifying the trade amount and the platform's application_fee_amount
  3. Whop generates a checkout link
  4. The buyer is directed to complete payment through the Whop-hosted checkout
  5. On successful payment, Whop fires a payment.succeeded webhook

Notice the redirect_url parameter in our checkout configuration - this tells Whop where to send the buyer after they complete (or cancel) payment.

Whop appends query parameters to this URL including payment_id and checkout_status, which our callback route uses to verify the payment server-side. Without redirect_url, the buyer ends up on a generic Whop page instead of back in your app.

The BidForm component handles this automatically - when a bid matches immediately (either through "Place Bid" at a matching price or "Buy Now"), the component detects { matched: true, trade: { id } } in the API response, calls POST /api/trades/{tradeId}/checkout to get the checkout URL, and redirects the buyer to Whop's payment page.

Buyers can also reach checkout from the trade detail page (we'll cover that later down the article) if they navigate away before paying.

In this tutorial, we're redirecting the buyers to a Whop hosted checkout page, but if you'd prefer to keep the buyer on your site, Whop also offers an embedded checkout component.

The escrow pattern

One thing we should clear up is that payments does not mean payout. When a buyer pays, the funds are held instead of directly being transferred to sellers.

This is an escrow pattern and it protects buyers from receiving misrepresented or wrong items.

The trade moves through these statuses after payment:

  1. PAID - Buyer has been charged. Funds held via Whop
  2. SHIPPED - Seller has shipped the item to the platform for authentication. Seller provides tracking number
  3. AUTHENTICATING - Item received by the platform. Admin review in progress
  4. VERIFIED - Item passes authentication. Payout released to seller
    Each status transition is an event. Each event triggers notifications (which we'll look at in part six) and potentially a financial action. The webhook handler and admin actions taken from your platform moderators drive these transitions.

Payout release

When an admin of your platform marks an item as verified, the platform releases the payout to the seller.

Since you're using direct charges, the funds are already linked to the seller's connected account. The payout goes to whatever method the seller selected during KYC.

Refund on Authentication Failure

If the item fails authentication, the project follows the a reverse flow:

  1. Admin marks the item as FAILED
  2. Trade status moves to FAILED
  3. Buyer is refunded via Whop (full refund of the original charge)
  4. Item is returned to the seller
  5. The seller's original ask can be reposted
  6. Both buyer and seller receive notifications explaining the outcome
    The refund is processed through Whop's API against the original charge. Because we stored the whopPaymentId on the trade's Payment record, we have the reference we need.

Webhook handler

After the buyer completes the payment, Whop sends an POST message to your webhook endpoint with the result. Your webhook handler then verifies the signature, updates the database, and notifies both parties in-app.

Before creating the handler file, you need the Whop SDK wrapper. It uses lazy initialization (same Proxy pattern as env.ts) so it doesn't crash during builds.
To install the Whop SDK, run the command below in your terminal:

Terminal
npm install @whop/sdk

Then, go to src/lib and create a file called whop.ts with the content:

whop.ts
import { Whop } from "@whop/sdk";
import { env } from "@/lib/env";

let _whopsdk: Whop | undefined;

export function getWhopSDK(): Whop {
  if (!_whopsdk) {
    _whopsdk = new Whop({
      appID: env.WHOP_APP_ID,
      apiKey: env.WHOP_API_KEY,
      webhookKey: btoa(env.WHOP_WEBHOOK_SECRET),
      baseURL: `${env.WHOP_API_BASE}/api/v1`,
    });
  }
  return _whopsdk;
}

export const whopsdk = new Proxy({} as Whop, {
  get(_, prop) {
    const sdk = getWhopSDK();
    const value = sdk[prop as keyof Whop];
    if (typeof value === "function") {
      return value.bind(sdk);
    }
    return value;
  },
});

The baseURL is built from WHOP_API_BASE. The same env var that controls sandbox vs production for OAuth (which we've covered in part two).
In development, this points to https://sandbox-api.whop.com/api/v1 so that you can simulate Whop flows without moving real money. In production, you'll change it to https://api.whop.com/api/v1.

The webhook handler uses waitUntil from @vercel/functions to process events asynchronously after returning 200. You can install it using the command below:

Terminal
npm install @vercel/functions

Now, let's go to src/app/api/webhooks/whop and create a file called route.ts with the content:

route.ts
import { NextRequest } from "next/server";
import {
  PaymentStatus,
  TradeStatus,
  BidStatus,
  AskStatus,
  NotificationType,
} from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { whopsdk } from "@/lib/whop";
import { prisma } from "@/lib/prisma";
import { sendSystemMessage } from "@/services/chat";

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

    let webhookData: { type: string; data: Record<string, unknown> };
    try {
      webhookData = (await whopsdk.webhooks.unwrap(bodyText, {
        headers,
      })) as unknown as {
        type: string;
        data: Record<string, unknown>;
      };
    } catch {
      return new Response("Invalid webhook signature", { status: 401 });
    }

    waitUntil(processWebhook(webhookData));

    return new Response("OK", { status: 200 });
  } catch (error: unknown) {
    console.error("Webhook handler error:", error);
    return new Response("OK", { status: 200 });
  }
}

async function processWebhook(webhookData: {
  type: string;
  data: Record<string, unknown>;
}) {
  try {
    const paymentId = webhookData.data.id as string | undefined;
    if (!paymentId) return;

    // Idempotency check - skip if already processed
    const existingPayment = await prisma.payment.findFirst({
      where: { whopPaymentId: paymentId },
    });
    if (existingPayment) return;

    const tradeId = webhookData.data.metadata
      ? ((webhookData.data.metadata as Record<string, unknown>).tradeId as string | undefined)
      : undefined;

    switch (webhookData.type) {
      case "payment.succeeded": {
        if (!tradeId) return;

        const trade = await prisma.trade.findUnique({
          where: { id: tradeId },
        });
        if (!trade) return;

        await prisma.$transaction(async (tx) => {
          await tx.payment.create({
            data: {
              tradeId: trade.id,
              whopPaymentId: paymentId,
              amount: trade.price,
              platformFee: trade.platformFee,
              status: PaymentStatus.SUCCEEDED,
              idempotencyKey: `payment_succeeded_${paymentId}`,
            },
          });

          await tx.trade.update({
            where: { id: trade.id },
            data: { status: TradeStatus.PAID },
          });

          await tx.notification.createMany({
            data: [
              {
                userId: trade.buyerId,
                type: NotificationType.TRADE_COMPLETED,
                title: "Payment confirmed",
                message: `Your payment of $${trade.price.toFixed(2)} has been confirmed.`,
                metadata: { tradeId: trade.id },
              },
              {
                userId: trade.sellerId,
                type: NotificationType.ITEM_SHIPPED,
                title: "New sale - ship your item",
                message: `A buyer has paid $${trade.price.toFixed(2)}. Please ship your item for authentication.`,
                metadata: { tradeId: trade.id },
              },
            ],
          });
        });

        if (trade.chatChannelId) {
          await sendSystemMessage(
            trade.chatChannelId,
            "Payment confirmed! Seller, please ship your item for authentication."
          );
        }

        break;
      }

      case "payment.failed": {
        if (!tradeId) return;

        const trade = await prisma.trade.findUnique({
          where: { id: tradeId },
          include: { bid: true, ask: true },
        });
        if (!trade) return;

        await prisma.$transaction(async (tx) => {
          await tx.payment.create({
            data: {
              tradeId: trade.id,
              whopPaymentId: paymentId,
              amount: trade.price,
              platformFee: trade.platformFee,
              status: PaymentStatus.FAILED,
              idempotencyKey: `payment_failed_${paymentId}`,
            },
          });

          await tx.trade.update({
            where: { id: trade.id },
            data: { status: TradeStatus.FAILED },
          });

          if (trade.bid) {
            await tx.bid.update({
              where: { id: trade.bid.id },
              data: { status: BidStatus.ACTIVE },
            });
          }

          if (trade.ask) {
            await tx.ask.update({
              where: { id: trade.ask.id },
              data: { status: AskStatus.ACTIVE },
            });
          }

          await tx.notification.create({
            data: {
              userId: trade.buyerId,
              type: NotificationType.ITEM_FAILED,
              title: "Payment failed",
              message: "Your payment could not be processed. Your bid has been reopened.",
              metadata: { tradeId: trade.id },
            },
          });
        });

        if (trade.chatChannelId) {
          await sendSystemMessage(
            trade.chatChannelId,
            "Payment failed. The bid and ask have been reopened."
          );
        }

        break;
      }
    }
  } catch (error: unknown) {
    console.error("Webhook processing error:", error);
  }
}

Key patterns in this handler:

  • Signature verification first - whopsdk.webhooks.unwrap() verifies the request came from Whop. If it fails, returns 401 and stops
  • Return 200 immediately - Whop retries webhooks that don't get a 200. We return right away and use waitUntil from @vercel/functions to process in the background
  • Idempotency - Webhooks can arrive more than once. Before processing, we check if a Payment with this whopPaymentId already exists. If so, skip. The unique constraint on whopPaymentId in the database protects against race conditions too
  • Single transaction - Payment record, trade status update, and notifications all happen inside one Prisma $transaction. Everything succeeds or nothing does
  • Bid/ask reopening on failure - A failed payment kills the trade but reopens the original bid and ask so the matching engine can find new counterparties

Payment verification callback

Webhooks are the primary mechanism for learning about payment outcomes, but they're not the only one.

After the buyer completes payment on Whop's checkout page, Whop redirects them back to the redirect_url we set on the checkout configuration, with payment_id and checkout_status as query parameters. We use this redirect to verify the payment server-side as a fallback.

Create the callback route by going to the src/app/api/trades/[id]/payment-callback folder and creating a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import {
  PaymentStatus,
  TradeStatus,
  NotificationType,
} from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
import { getPaymentStatus } from "@/services/whop";
import { sendSystemMessage } from "@/services/chat";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: tradeId } = await params;
  const paymentId = request.nextUrl.searchParams.get("payment_id");
  const checkoutStatus = request.nextUrl.searchParams.get("checkout_status");

  const dashboardUrl = `${env.NEXT_PUBLIC_APP_URL}/dashboard`;

  if (!tradeId || !paymentId) {
    return NextResponse.redirect(`${dashboardUrl}?payment=error`);
  }

  try {
    const trade = await prisma.trade.findUnique({
      where: { id: tradeId },
    });

    if (!trade) {
      return NextResponse.redirect(`${dashboardUrl}?payment=error`);
    }

    if (trade.status === TradeStatus.PAID) {
      return NextResponse.redirect(
        `${dashboardUrl}?payment=success&tradeId=${tradeId}`
      );
    }

    if (checkoutStatus !== "success") {
      return NextResponse.redirect(
        `${dashboardUrl}?payment=failed&tradeId=${tradeId}`
      );
    }

    const payment = await getPaymentStatus(paymentId);
    const whopPayment = payment as {
      status?: string;
      substatus?: string;
    };
    const isPaid =
      whopPayment.status === "paid" ||
      whopPayment.substatus === "succeeded";

    if (isPaid) {
      const existingPayment = await prisma.payment.findFirst({
        where: { whopPaymentId: paymentId },
      });

      if (!existingPayment) {
        await prisma.$transaction(async (tx) => {
          await tx.payment.create({
            data: {
              tradeId: trade.id,
              whopPaymentId: paymentId,
              amount: trade.price,
              platformFee: trade.platformFee,
              status: PaymentStatus.SUCCEEDED,
              idempotencyKey: `payment_callback_${paymentId}`,
            },
          });

          await tx.trade.update({
            where: { id: trade.id },
            data: { status: TradeStatus.PAID },
          });

          await tx.notification.createMany({
            data: [
              {
                userId: trade.buyerId,
                type: NotificationType.TRADE_COMPLETED,
                title: "Payment confirmed",
                message: `Your payment of $${trade.price.toFixed(2)} has been confirmed.`,
                metadata: { tradeId: trade.id },
              },
              {
                userId: trade.sellerId,
                type: NotificationType.ITEM_SHIPPED,
                title: "New sale - ship your item",
                message: `A buyer has paid $${trade.price.toFixed(2)}. Please ship your item for authentication.`,
                metadata: { tradeId: trade.id },
              },
            ],
          });
        });
      }

      if (trade.chatChannelId) {
        await sendSystemMessage(
          trade.chatChannelId,
          "Payment confirmed! Seller, please ship your item for authentication."
        );
      }

      return NextResponse.redirect(
        `${dashboardUrl}?payment=success&tradeId=${tradeId}`
      );
    }

    return NextResponse.redirect(
      `${dashboardUrl}?payment=pending&tradeId=${tradeId}`
    );
  } catch (error: unknown) {
    console.error("Payment callback error:", error);
    return NextResponse.redirect(
      `${dashboardUrl}?payment=error&tradeId=${tradeId}`
    );
  }
}

This route does the same work as the webhook handler - create a Payment record, update the trade to PAID, notify both parties - but it's triggered by the buyer's browser redirect instead of a server-to-server webhook.

The idempotency check ensures that if both the webhook and the callback fire, only the first one creates the Payment record. In production, webhooks usually arrive first; in sandbox, this callback is the primary path.

Trade details page

Every trade needs a detail page where the buyer can see the status, product info, parties involved, and - if the trade is waiting for payment - a "Pay Now" button. This page is also the destination when a user clicks a notification.

First, create a GET endpoint that returns a single trade with all its related data. Go to src/app/api/trades/[id] and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const user = await requireAuth();
    const { id } = await params;

    const trade = await prisma.trade.findUnique({
      where: { id },
      include: {
        productSize: {
          include: {
            product: {
              select: {
                id: true,
                name: true,
                brand: true,
                images: true,
                sku: true,
              },
            },
          },
        },
        buyer: { select: { id: true, username: true, displayName: true } },
        seller: { select: { id: true, username: true, displayName: true } },
        payment: true,
      },
    });

    if (!trade) {
      return NextResponse.json(
        { error: "Trade not found" },
        { status: 404 }
      );
    }

    if (trade.buyerId !== user.id && trade.sellerId !== user.id) {
      return NextResponse.json(
        { error: "Not authorized to view this trade" },
        { status: 403 }
      );
    }

    return NextResponse.json({ trade });
  } catch (error: unknown) {
    if (error instanceof Response) {
      return NextResponse.json(
        { error: "Authentication required" },
        { status: 401 }
      );
    }
    console.error("Failed to fetch trade:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

The endpoint is auth-gated - only the buyer or seller of the trade can view it.
Then create the trade detail page at src/app/trades/[id]/page.tsx. This is a client component that:

  • Fetches the trade from GET /api/trades/{id} on mount
  • Displays the product image, name, brand, size, and SKU
  • Shows a status badge using the ORDER_STATUSES map from /src/constants/index.ts
  • For trades in MATCHED status (awaiting payment), shows a prominent "Pay Now" button that calls POST /api/trades/{id}/checkout and redirects to Whop checkout
  • Displays trade details in a grid: price, platform fee, the user's role (buyer/seller), and trade date
  • Shows both parties (buyer and seller names)
  • If a payment exists, shows payment status, amount, Whop payment ID, and date
  • Links to the product page for quick navigation back

This page serves two purposes: it's the landing page when a user clicks a trade-related notification (bid matched, payment confirmed, item shipped, etc.), and it's the place where a buyer who didn't complete checkout immediately can return to pay.

At this point you should have:

  • Seller onboarding flow with connected account creation and KYC
  • Checkout configuration creation on bid/ask match using Direct Charges
  • BidForm auto-redirect to Whop checkout when a bid matches immediately
  • Payment redirect back to your app via redirect_url
  • Payment verification callback as a webhook fallback
  • Escrow pattern: payment held until authentication passes
  • Payout release after successful verification
  • Refund flow for failed authentication
  • Webhook handler at /api/webhooks/whop/route.ts with signature verification
  • Idempotency checks on all webhook and callback event processing
  • Notifications sent on payment success and failure
  • Trade detail page at /trades/[id] with "Pay Now" for pending trades
  • Trade GET endpoint at /api/trades/[id] with auth gating

Part 5: Product authentication, product pages, and market data

So far, you have a matching engine that links bids with asks and a payments system that charges buyers and holds funds in escrow. One of the most critical parts of moving money between buyers and sellers is the item authentication, where you verify the item's legitimacy before releasing funds.

This authentication flow is what makes StockX work - instead of a trust-based system, buyers have their backs covered by the platform. This project replicates this with an admin review flow.

If you don't need the authentication for your marketplace, you can skip this section and use a trust-based system instead where payment release happens automatically after seller ships the item. The escrow flow from Part 4 still works, you'd just remove the AUTHENTICATING step and move directly from SHIPPED to DELIVERED.

The status state machine

Every trade follows a linear path with one decision point:

  • MATCHED -> PAID: Triggered by the payment.succeeded webhook (Part 4)
  • PAID -> SHIPPED: Triggered when the seller submits a tracking number
  • SHIPPED -> AUTHENTICATING: Triggered when the platform confirms receipt of the item
  • AUTHENTICATING -> VERIFIED: Admin marks the item as passing authentication
  • AUTHENTICATING -> FAILED: Admin marks the item as failing authentication
  • VERIFIED -> DELIVERED: Shipping confirmation shows the item was delivered to the buyer
  • FAILED -> REFUNDED: Buyer refund is processed, item returned to seller

What happens on failure

If an items fails the authentication these steps occur:

  • The trade status moves to FAILED
  • The buyer receives a full refund via Whop (against the original charge)
  • The seller is notified that authentication failed, with the reason
  • The item is flagged for return shipping to the seller
  • Once the seller receives the item back, they can optionally relist it (create a new ask)
  • The original bid is also reopened, the buyer still wants the product, just not a counterfeit one

The failure reason matters. "Wrong size sent" is different from "Counterfeit item." Your admin panel should capture this and display it to the seller.

Admin review process

The admin control flow should be handled by the admin panel, protected behind an admin role check at the admin/authentication. It requires a list of trades with the AUTHENTICATING status (oldest to newest) and, in addition, should have easy-to-use approve/reject buttons.

In the event of a rejection, admins must provide an explanation. An audit trail is generated for all actions taken by admins.

Centralized product pages

This project will utilise centralised product pages. On sites such as eBay or Etsy, each product, for example the Nike Jordan 1 Royal, has individual pages linked to sellers, and each has different images and prices. On StockX, each product has a single page, and sellers' asks and buyers' bids are aggregated on a single canonical page.

This gives the platform control over the product catalogue. Instead of listing a product, sellers create an ask on an existing product page. If the product is not available on the platform, admins can add it to the catalogue. This centralisation enables the bid and ask market model to function.

Each product page needs:

  • Product images (platform-controlled, not seller-uploaded)
  • Product name, brand, SKU, colorway, release date
  • Description and specifications
  • Retail price (for reference - the market price will be different)
  • Size picker that switches the entire page's market context
  • Order book showing current bids and asks for the selected size
  • Price history chart for the selected size
  • Bid and Ask action buttons

Let's create the product page route. Go to src/app/products/[id] and create a file called page.tsx with the content:

page.tsx
export const dynamic = "force-dynamic";

import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ProductDetail } from "@/components/ProductDetail";

async function getProduct(id: string) {
  const product = await prisma.product.findUnique({
    where: { id },
    include: {
      sizes: {
        orderBy: { size: "asc" },
        include: {
          trades: {
            select: { price: true, createdAt: true },
            orderBy: { createdAt: "asc" },
            take: 100,
          },
        },
      },
    },
  });
  return product;
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  const allTrades = product.sizes.flatMap((s) =>
    s.trades.map((t) => ({
      price: t.price,
      createdAt: t.createdAt.toISOString(),
    }))
  );

  const totalSales = product.sizes.reduce((sum, s) => sum + s.salesCount, 0);
  const avgPrice =
    allTrades.length > 0
      ? allTrades.reduce((sum, t) => sum + t.price, 0) / allTrades.length
      : null;
  const lastSale =
    allTrades.length > 0 ? allTrades[allTrades.length - 1].price : null;
  const premiumDiscount =
    lastSale !== null
      ? (((lastSale - product.retailPrice) / product.retailPrice) * 100).toFixed(0)
      : null;

  const serializedSizes = product.sizes.map((s) => ({
    id: s.id,
    size: s.size,
    lowestAsk: s.lowestAsk,
    highestBid: s.highestBid,
    lastSalePrice: s.lastSalePrice,
    salesCount: s.salesCount,
  }));

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <ProductDetail
        product={{
          id: product.id,
          name: product.name,
          brand: product.brand,
          sku: product.sku,
          description: product.description,
          images: product.images,
          retailPrice: product.retailPrice,
          category: product.category,
        }}
        sizes={serializedSizes}
        trades={allTrades}
        marketSummary={{
          lastSale,
          avgPrice,
          totalSales,
          premiumDiscount,
        }}
      />
    </div>
  );
}

The size/SKU picker

Sizes or product types on the platform have their own independent marketplaces. A size 10 Air Jordan 1 Royal may be sold for $180, while a size 13 may be sold for $220. The size and product type selector controls transitions between these marketplaces. When a user selects a size or type:

  • The lowest ask and highest bid update to reflect that size's order book
  • The price history chart redraws with that size's trade history
  • The bid and ask forms pre-fill with the selected size
  • The "last sale" price updates
    Requirements for the size picker are:
  • Grid layout showing all available sizes
  • Each size tile displays the lowest ask price for that size (or "No Asks" if none exist)
  • Visual highlight on the selected size
  • Sizes with no market activity should still be selectable (users should be able to place the first bid or ask)
  • URL state: selecting a size updates the URL query parameter (?size=10) so the page is shareable and bookmarkable
  • Default to the most popular size (highest trade volume) when no size is specified

Real-time market data

Markets without live price feeds can feel dull. If a user browsing a product page doesn't see the data when someone else places a bid on the same product, the experience StockX promises is ruined.

That's why we used Supabase Realtime in part one – it allows us to use real-time database changes without having to develop our own WebSocket infrastructure.

Live price ticker

Products and their sizes or types are updated in real time with their latest sale prices, and all users viewing the products can see the new prices without having to refresh the page whenever a transaction is completed on the platform.

This is achieved by Supabase Realtime tracking the Trade table and filtering by product.
Requirements for the live ticker:

  • Subscribe to new trades for the currently viewed product/size
  • Update the "Last Sale" price immediately when a new trade is inserted
  • Show a brief visual indicator (green/red flash) when the price changes
  • Unsubscribe when the user navigates away (cleanup on unmount)
  • Handle connection drops by reconnecting and fetching the latest price

The useRealtimeBids hook we created in part three powers this.

Price history chart

The chart on product pages lists prices that change over time. This is one of StockX's most recognisable features and presents product pages to users with an experience similar to a stock chart.

Data points the chart needs:

  • Last sale price: the most recent trade price (real-time)
  • Average sale price (30-day): rolling average over the past 30 days
  • Total sales volume: number of trades for this product/size, shown as context
  • Highest sale: the peak price this product/size has ever traded at
  • Lowest sale: the floor price
  • Premium/discount vs. retail: current market price as a percentage above or below the original retail price (e.g., "+42% above retail")
    Charts should also support timeframe options such as 1 day, 7 days, 30 days, 90 days, 1 year, and all time. Each timeframe selection reloads the price history and refreshes the chart.
    Requirements for the price chart:
  • Line chart with trade price on the Y axis and date on the X axis
  • Time range selector (1D, 7D, 30D, 90D, 1Y, ALL)
  • Hover tooltip showing exact price and date for each data point
  • Volume bars along the bottom (optional but adds context)
  • Summary stats displayed alongside the chart (last sale, average, high, low, premium/discount)
  • Responsive: works on mobile screens without losing readability

Part 6: Search, notifications, and deployment

The notification flow design of the StockX clone project

So far, we have covered market logic, bid and ask matching, payment flow, escrow logic, product verification, product pages, and live market data.

This final section will address the features that will enable you to use the project on a large scale: search, notifications, and user dashboards.

Markets hosting hundreds or even thousands of products require powerful search capabilities. Users visiting the site with the intention to purchase will have specific product names in mind and should be able to find them easily.

We will avoid using external systems by leveraging PostgreSQL's built-in text search feature via Prisma. The search indexes product name, brand, and SKU using tsvector columns, and queries use tsquery with ranking.

Requirements for search:

  • Search across product name, brand, and SKU simultaneously
  • Debounced input: don't fire a query on every keystroke, wait 300ms after the user stops typing
  • Autocomplete suggestions: show a dropdown of matching products as the user types, with product images and current lowest ask price
  • Highlighted matches: bold the matching portion of each result so the user can scan quickly
  • Empty state: show trending products when the search box is empty or has no results
  • Keyboard navigation: arrow keys to move through suggestions, Enter to select
  • Mobile-friendly: full-screen search overlay on small screens

Create the search component at src/components/SearchBar.tsx. This component manages filter state locally, debounces the search input (300ms delay after the user stops typing), and calls an onChange callback with the current filters.

It renders a search input, dropdowns for category/brand/size, min/max price fields, and removable filter pills for active filters. The CATEGORIES constant from /src/constants/index.ts populates the category dropdown; brand and size options are passed as props from the parent page.

Filter system

Users can find products using the search system you have developed, but they also need filtering systems to find the specific products and types they want.

Available filters:

  • Category: Sneakers, Streetwear, Electronics, Collectibles (or whatever categories your catalog uses)
  • Brand: Nike, Adidas, New Balance, etc. dynamically populated from your product catalog
  • Size: Varies by category, shoe sizes for sneakers, clothing sizes for streetwear
  • Price range: Min/max slider or input fields, filtering by lowest ask price
    Requirements for the filter system:
  • URL-persisted filter state: every filter combination maps to a URL query string (?category=sneakers&brand=nike&minPrice=100). Users can bookmark filtered views and share links
  • Combinable filters: all filters work together (brand + size + price range narrows results progressively)
  • Result count update: show how many products match the current filter combination, updating as filters change
  • Clear filters: one-click reset to remove all active filters
  • Filter pills: show active filters as removable chips/pills above the results grid
  • Responsive sidebar: filters in a collapsible sidebar on desktop, a bottom sheet or modal on mobile

Home and discover pages require systems to bring attention-grabbing products to users. This increases engagement while also allowing new users to discover other content offered by your platform.

The trending algorithm tracks new activity signals and determines a score:

  • Trade volume (24h): products that are actively selling rank higher
  • Bid count (24h): products with many active bids have demand even if they haven't traded recently
  • Price movement: products with significant price changes (up or down) are interesting, a 15% price jump in a day is newsworthy
    You can weight these however makes sense for your catalog. A simple starting point: score = (trades_24h * 3) + (bids_24h * 1) + (abs(price_change_pct) * 2). Adjust the multipliers based on what produces good results with your data.
    Requirements:
  • Trending section on the homepage showing the top 8-12 products by trending score
  • "Most Popular" as a sort option on browse/search pages
  • Recalculate trending scores periodically (every 15 minutes via a cron job, or on-demand with caching)
  • Show the signal: "12 sales today" or "+8% this week" alongside trending products so users understand why they're featured

Custom pagination

Due to the nature of the platform, product lists can be very long, and most likely will be.

While other marketplace sites solve this problem through methods such as infinite scroll, there are drawbacks, the biggest being performance. Instead of such solutions, you will use a page-based solution in this project.

Requirements for pagination:

  • Page size: configurable, default 40 products per page
  • Total count: display "Showing 41-80 of 1,247 products" so users know the scale
  • Next/previous navigation with page numbers
  • URL-persisted page state: ?page=3 or ?cursor=abc123 in the URL
  • Preserve filter state across pages, changing pages shouldn't reset active filters
    Create the pagination component at src/components/Pagination.tsx. It takes currentPage, totalPages, and an onPageChange callback. The component renders first/previous/next/last buttons with SVG icons, page numbers with ellipsis for large ranges (showing at most 7 page buttons), and disables navigation buttons at the boundaries. It returns null when totalPages is 1 or less.

In-app notification system

Users should receive notifications when an interaction requiring their attention occurs (such as a bid matching or a payment completing).

Let's set up this system. We will deliver notifications to users within the application without using any external services. A notification button in the navigation bar should display the user's notification count and, when clicked, open a menu listing the notifications.

Notification triggers:

  • Bid matched: your bid found a seller at your price or lower - "Your bid for Air Jordan 15 (Size 10) was matched at $285"
  • Ask matched: your ask found a buyer - "Your Air Jordan 15 (Size 10) sold for $290. Ship it for authentication."
  • Payment confirmed: charge went through - "Payment of $285 confirmed for your purchase"
  • Payment failed: charge failed - "Payment failed. Your bid has been reopened."
  • Item shipped: seller provided tracking - "Your item has shipped. Tracking: [number]"
  • Authentication complete: item was verified - "Your Jordan 4 Bred passed authentication and is on its way"
  • Authentication failed: item failed verification - "Authentication failed. A refund has been initiated."
  • Price alert: a product on your watchlist hit a target price - "Air Jordan 15 (Size 10) dropped below your $250 alert"
    Requirements for the notification system:
  • Notification table in the database (already in the Prisma schema) with fields: userId, type, message, metadata (JSON), read status, createdAt
  • Bell icon in the nav bar with a badge showing the unread count
  • Dropdown panel showing the most recent notifications, grouped by read/unread
  • Mark individual notifications as read (on click)
  • "Mark all as read" button
  • Notification detail: clicking a notification navigates to the relevant page (trade detail, product page, dashboard)
  • Polling or Supabase Realtime subscription to check for new notifications without page refresh
  • Notification preferences: let users choose which notification types they want (future enhancement - start with all enabled)

Create the notification hook at src/hooks/useNotifications.ts. This hook fetches notifications from /api/notifications, subscribes to Supabase Realtime on the Notification table filtered by userId for live updates, and exposes markAsRead (patches a single notification) and markAllAsRead (patches all unread). It returns { notifications, unreadCount, markAsRead, markAllAsRead, isLoading }.

Then create the notification button component in the src/components/NotificationBell.tsx location. It should use a useNotifications hook and have a bell icon with a notification count badge. Clicking a notification should mark it as read and - if the notification's metadata contains a tradeId - navigate to /trades/{tradeId} (the trade detail page from part four).

This gives users a direct path from "Your bid was matched" to the trade page where they can pay or track status. Use Next.js useRouter for the navigation and close the dropdown on click.

User dashboards

User dashboards should provide users with information such as active orders, history, and product positions. Naturally, these dashboards should vary depending on the user type (seller, buyer, or both).

The buyer dashboard shows:

  • Active bids: all open bids with current status, product info, and a cancel option
  • Purchase history: completed trades with dates, prices, and authentication status
  • Items awaiting delivery: trades that have been verified and are in transit
  • Portfolio value: the total market value of items the buyer owns, based on current lowest ask prices
    The seller dashboard shows:
  • Active asks: all open asks with current status, product info, and ability to update price or cancel
  • Sales history: completed trades with dates, sale price, fees, and net earnings
  • Items to ship: trades in PAID status that need the seller to ship for authentication - this is action-required, so make it prominent
  • Earnings summary: total earnings, pending payouts, completed payouts

Order history

A filterable, sortable table showing every trade the user has been involved in:

  • Trade date, product, size, price, role (buyer/seller), status
  • Filters by status (active, completed, failed), role, date range
  • Sortable by date, price, or status
  • Expandable rows showing trade detail (tracking number, authentication result, payment info)

Create the dashboard page at src/app/dashboard/page.tsx. This is a client component with four tabs - Overview, Buying, Selling, and Portfolio. It uses a usePortfolio hook (at src/hooks/usePortfolio.ts) that loads the user's active bids, asks, completed trades, payment totals, and portfolio value from /api/user/portfolio.

The Overview tab shows summary cards, the Buying tab shows active bids and purchase history tables, the Selling tab gates behind seller onboarding - if the user hasn't completed Whop KYC, it shows a "Become a Seller" button that triggers POST /api/sellers/onboard and redirects to the Whop-hosted verification flow, otherwise it shows active asks.

The Portfolio tab shows each owned item with purchase price, current market price, and gain/loss percentage. The same onboarding gate applies to the AskForm component on product pages - both use the useCurrentUser hook (from part four) to check connectedAccountId.

Deployment and production readiness

Your app has been running on Vercel since part one, so let's take a look at some important factors to consider before you move the project to production:

  • You should use the Whop API (https://api.whop.com) for the WHOP_API_BASE environment variable on production. For preview and development, you used Whop sandbox (https://sandbox-api.whop.com)
  • Make sure WHOP_API_KEY is a company API key in all environments, and WHOP_COMPANY_ID is set correctly
  • Your webhook signature verification is active and tested (in part four), rate limiting applies to all API routes, and each has Zod validation.
  • There are no secrets in NEXT_PUBLIC_ environment variables
  • CORS and CSRF configurations are set up
  • Sanitized input for storage and render

Part 7: Buyer-seller chat with Whop embedded components

The trade details page design

So far, the project has authentication, bid/ask matching, payments, product pages, search, and notifications. One thing is missing: buyers and sellers can't talk to each other.

We'll add chat to every trade using Whop's embedded chat components - drop-in UI that handles messaging, presence, and real-time updates without building any chat infrastructure.

Why Whop embedded chat?

Building a chat system from scratch means: message storage, WebSocket connections, delivery guarantees, typing indicators, read receipts, media uploads, moderation. That's a project on its own.

Whop provides:

  • A pre-built chat UI that drops into any React app
  • Real-time messaging over WebSockets (handled for you)
  • DM channels scoped to your company
  • Short-lived access tokens so your server controls who can chat
  • No OAuth scope changes - access tokens bypass OAuth entirely

The architecture is simple: your server creates DM channels and tokens via the Whop API, and the client renders the chat UI using those tokens.

What we're building

  • Auto-create a DM channel between buyer and seller when a trade matches
  • Show the chat side-by-side with trade details (stacked on mobile)
  • Send automated system messages on trade status changes (payment confirmed, payment failed)
  • Add a chat icon in the navbar linking to the dashboard

Step 1: Install packages

You need to install two packages: the React wrapper components and the underlying vanilla JS runtime:

Terminal
npm install @whop/embedded-components-react-js@0.0.13-beta.4 @whop/embedded-components-vanilla-js@0.0.13-beta.4
The chat components are in the 0.0.13-beta release. The stable 0.0.12 only has payouts components.

Step 2: Whop dashboard permissions

Your company API key needs these permissions for chat to work:

Permission Purpose
chat:read Read chat messages
chat:message:create Send messages (system messages)
dms:read List and retrieve DM channels
dms:message:manage Manage messages in DM channels
dms:channel:manage Create and manage DM channels

Go to your company dashboard, Settings, API Keys and enable these on the same API key you're using for WHOP_API_KEY.

Step 3: Environment variable

The embedded chat component needs to know whether to connect to Whop's sandbox or production environment. Add this to your Vercel environment variables:

Variable Value Purpose
NEXT_PUBLIC_WHOP_ENVIRONMENT sandbox (dev/preview) or production (prod) Tells the embedded chat which Whop environment to connect to

This is a NEXT_PUBLIC_ variable because the chat component runs in the browser and needs to know the environment at runtime.

Step 4: What was already set up

Several chat integration points were already built into earlier parts of this tutorial:

  • Part 2: The chatChannelId field on the Trade model stores the Whop DM channel ID
  • Part 3: The matching engine's setupTradeChat function auto-creates a DM channel when a trade matches and sends an initial system message
  • Part 4: The webhook handler and payment callback both send system messages to the trade's chat channel on payment success/failure

The rest of this part covers the new files needed to complete the chat feature.

Step 5: Chat service

Go to src/services and create a file called chat.ts with the content below, including three functions that call the Whop API directly. These endpoints aren't in @whop/sdk@0.0.27, so we use fetch.

chat.ts
import { env } from "@/lib/env";

export async function createAccessToken(oauthToken: string): Promise<string> {
  const res = await fetch(`${env.WHOP_API_BASE}/api/v1/access_tokens`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${oauthToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({}),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to create access token: ${res.status} ${text}`);
  }

  const data = (await res.json()) as { token: string };
  return data.token;
}

export async function createDmChannel(
  buyerWhopId: string,
  sellerWhopId: string,
  tradeName: string
): Promise<string> {
  const res = await fetch(`${env.WHOP_API_BASE}/api/v1/dm_channels`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.WHOP_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      with_user_ids: [buyerWhopId, sellerWhopId],
      company_id: env.WHOP_COMPANY_ID,
      custom_name: tradeName,
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to create DM channel: ${res.status} ${text}`);
  }

  const data = (await res.json()) as { id: string };
  return data.id;
}

export async function sendSystemMessage(
  channelId: string,
  content: string
): Promise<void> {
  const res = await fetch(`${env.WHOP_API_BASE}/api/v1/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.WHOP_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ channel_id: channelId, content }),
  });

  if (!res.ok) {
    const text = await res.text();
    console.error(`Failed to send system message: ${res.status} ${text}`);
  }
}

Step 6: Token endpoint

The embedded chat component needs a token to authenticate. Let's create a simple endpoint that returns one for the logged-in user. Go to src/app/api/token and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { createAccessToken } from "@/services/chat";

export async function GET() {
  try {
    const session = await getSession();

    if (!session.accessToken) {
      return NextResponse.json(
        { error: "Authentication required" },
        { status: 401 }
      );
    }

    const token = await createAccessToken(session.accessToken);
    return NextResponse.json({ token });
  } catch (error: unknown) {
    console.error("Token creation error:", error);
    return NextResponse.json(
      { error: "Failed to create token" },
      { status: 500 }
    );
  }
}

Step 7: TradeChat component

Now, let's create the React component that renders the embedded chat. Go to src/components and create a file called TradeChat.tsx with the content:

TradeChat.tsx
"use client";

import { useMemo } from "react";
import {
  ChatElement,
  ChatSession,
  Elements,
} from "@whop/embedded-components-react-js";
import { loadWhopElements } from "@whop/embedded-components-vanilla-js";
import type { ChatElementOptions } from "@whop/embedded-components-vanilla-js/types";

const whopEnvironment =
  (process.env.NEXT_PUBLIC_WHOP_ENVIRONMENT as "sandbox" | "production") ||
  "production";

const elements = loadWhopElements({ environment: whopEnvironment });

async function getToken({ abortSignal }: { abortSignal: AbortSignal }) {
  const response = await fetch("/api/token", { signal: abortSignal });
  const data = await response.json();
  return data.token;
}

interface TradeChatProps {
  channelId: string | null;
}

export function TradeChat({ channelId }: TradeChatProps) {
  const chatOptions: ChatElementOptions = useMemo(() => {
    return { channelId: channelId ?? "" };
  }, [channelId]);

  if (!channelId) {
    return (
      <div className="flex items-center justify-center h-64 text-gray-500 text-sm">
        Chat will be available once the trade is matched.
      </div>
    );
  }

  return (
    <Elements elements={elements}>
      <ChatSession token={getToken}>
        <ChatElement
          options={chatOptions}
          style={{ height: "500px", width: "100%" }}
        />
      </ChatSession>
    </Elements>
  );
}

Key points:

  • loadWhopElements({ environment }) runs once at module level - it loads the Whop JS runtime and connects to the correct environment (sandbox or production) based on NEXT_PUBLIC_WHOP_ENVIRONMENT
  • getToken({ abortSignal }) fetches from our /api/token endpoint. The ChatSession calls this automatically and refreshes before expiry. The abortSignal param lets the component cancel in-flight requests on unmount
  • Elements provides the Whop runtime context to child components
  • ChatSession manages authentication state
  • ChatElement renders the actual chat UI for a specific channel

Step 8: Trade detail page - side-by-side layout

Update the src/app/trades/[id]/page.tsx file to show the chat alongside trade details.

Add the import at the top:

page.tsx
import { TradeChat } from "@/components/TradeChat";

Add chatChannelId to the Trade interface:

interface Trade {
  id: string;
  price: number;
  platformFee: number;
  chatChannelId: string | null;
  // ... rest is unchanged
}

Change the layout from a single column to a responsive grid. The key changes:

  • Add an isParticipant check before the return:
page.tsx
const isParticipant = currentUserId === trade.buyerId || currentUserId === trade.sellerId;
  • Replace the max-w-3xl container with a responsive width:
page.tsx
<div className={`mx-auto ${isParticipant && trade.chatChannelId ? "max-w-6xl" : "max-w-3xl"}`}>
  • Wrap the trade card and chat panel in a responsive grid:
page.tsx
<div className={`grid gap-6 ${isParticipant && trade.chatChannelId ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"}`}>
  {/* Existing trade card */}
  <div className="card p-6 space-y-6">
    {/* ... all existing trade details unchanged ... */}
  </div>

  {/* Chat Panel */}
  {isParticipant && trade.chatChannelId && (
    <div className="card p-4">
      <h2 className="text-sm font-medium text-gray-400 mb-3">Trade Chat</h2>
      <TradeChat channelId={trade.chatChannelId} />
    </div>
  )}
</div>

On desktop (lg breakpoint), the trade details and chat sit side-by-side. On mobile, the chat stacks below.

Step 9: Navbar chat icon

Add a chat bubble icon to the navbar that links to the dashboard (where users can find their trade chats).
Go to src/components and update the Navbar.tsx file to add a chat icon before the NotificationBell:

Navbar.tsx
{user && (
  <Link
    href="/dashboard"
    className="p-2 rounded-lg hover:bg-gray-800 transition-colors"
    title="Trade Chats"
  >
    <svg
      className="w-5 h-5 text-gray-400"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth="1.5"
      stroke="currentColor"
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
      />
    </svg>
  </Link>
)}
{user && <NotificationBell userId={user.id} />}

Step 10: Test it

Walk through the complete flow:

  1. Sign in with two different accounts (or use the same account for a self-match test)
  2. Place an ask at $50 for any product/size
  3. Place a bid at $50 for the same product/size - the matching engine creates a trade
  4. Navigate to /trades/{tradeId} - you should see the trade details on the left and a chat panel on the right
  5. Send a message in the chat - it appears in real-time
  6. Complete payment via the "Pay Now" button - after payment, a system message appears: "Payment confirmed! Seller, please ship your item for authentication."
  7. Check the navbar - the chat bubble icon appears next to the notification bell

Wrapping up the project

Over the course of this tutorial, we built a full StockX-style marketplace from scratch:

  1. Architecture (Part 1): chose the stack, set up the project, deployed to Vercel
  2. Data model and auth (Part 2): designed the Prisma schema, implemented Whop OAuth
  3. Matching engine (Part 3): built the bid/ask system that automatically matches trades
  4. Payments and escrow (Part 4): integrated Whop Payments Network infrastructure with Direct Charges, built the webhook handler
  5. Products and market data (Part 5): authentication flow, centralized product pages, real-time pricing
  6. Search, notifications, and deployment (Part 6): full-text search, in-app notifications, dashboards, production hardening
  7. Buyer-seller chat (Part 7): embedded Whop chat components, DM channels, system messages on trade events

At this point you should have:

  • Search component at src/components/SearchBar.tsx with debounced input, filters, and removable filter pills, Full-text search with autocomplete, filters, and URL-persisted state
  • Trending/most popular algorithm for product discovery, pagination component at src/components/Pagination.tsx with cursor-based custom pagination
  • Notification hook at src/hooks/useNotifications.ts with Supabase Realtime subscription, notification bell component at src/components/NotificationBell.tsx with unread count badge, dropdown, and click-to-navigate to trade detail pages
  • Dashboard page at src/app/dashboard/page.tsx with Overview, Buying, Selling, and Portfolio tabs, and ortfolio hook at src/hooks/usePortfolio.ts for fetching user trading data
  • Production environment variable separation (production vs. preview)
  • Security hardening: rate limiting, Zod validation, webhook verification, CORS, CSRF. Performance optimizations: ISR, edge caching, connection pooling
  • Chat service at src/services/chat.ts with access token, DM channel, and system message functions, and token endpoint at src/app/api/token/route.ts for embedded chat authentication
  • TradeChat component at src/components/TradeChat.tsx with Whop embedded chat UI, and auto-created DM channels on trade match via matching engine
  • System messages on payment success/failure in webhook handler and payment callback. Chat icon in navbar linking to dashboard
  • A complete, deployed, production-ready StockX clone

Where to go from here

  • Different product categories: the architecture supports any product type - sneakers, trading cards, electronics, vintage clothing. Add categories and adjust the authentication criteria
  • Analytics dashboard: trade volume over time, most-traded products, price movement trends, platform revenue
  • Mobile app: the API routes are already RESTful - a React Native or Flutter app could consume them directly
  • Seller ratings: track authentication pass rates per seller, display trust scores, restrict sellers with high failure rates
  • Price alerts via email/SMS: extend the notification system with external delivery channels for high-priority alerts

Start building your own StockX clone with Whop

That's everything you need to build a fully functional StockX clone using Whop infrastructure. If you haven’t already, go to Whop.com, create an account, and build your business.

If you want to learn more about the Whop Payments Network and the Whop infrastructure, visit our documentation.