I spent a few days building a StockX clone called Swaphause. Real-time bid/ask marketplace, escrow payments, seller identity verification, embedded buyer-seller chat per trade. It's a single Next.js project and the whole thing is on GitHub.
The matching engine and product pages were the parts I expected to be hard. They were interesting, but manageable, standard algorithms and React. What I was actually dreading was the marketplace infrastructure: escrow payments where funds are held until a product is authenticated, routing money to individual sellers, handling KYC so sellers can actually receive payouts, and building a real-time chat system so buyers and sellers can coordinate shipping. Those are the kinds of problems that turn a side project into a multi-month ordeal.
I ended up using Whop for all of it, connected accounts, KYC, escrow, webhooks, embedded chat, one SDK. I'll explain what I mean.
What's in the box
Swaphause is a bid/ask marketplace. Buyers place bids, sellers place asks, and when prices cross, a trade executes automatically. Here's what it does:
- Bid/ask matching engine with automatic trade execution
- Real-time pricing via Supabase Realtime
- Escrow payments via Whop for Platforms (connected accounts, KYC, fee splits)
- OAuth login through Whop (PKCE flow, iron-session cookies)
- Seller identity verification (KYC) handled by Whop's hosted flow
- Embedded buyer-seller chat per trade via Whop components
- Product authentication/verification admin flow
- Search, category filters, custom pagination
- User dashboard with portfolio, active orders, trade history
- In-app notifications for bids, payments, shipping
Stack: Next.js 15 (App Router), Prisma, Supabase, Whop SDK, Zod, deployed on Vercel.
It handles real money. You can deploy it and run a marketplace today.
How Whop made the hard parts easy
Escrow payments without being the merchant
This was the hardest problem. A marketplace like StockX doesn't just charge people, it holds funds in escrow until a product is verified as authentic, then pays the seller. If I'd built this from scratch I'd be dealing with payment holds, payout scheduling, refund logic, and compliance.
Whop's Direct Charge model handles the escrow pattern. The payment goes directly to the seller's connected account, the platform takes a percentage via application_fee_amount, and the funds are held until the product clears authentication. Swaphause is never the merchant of record.
Here's the checkout creation. This is the core of the payment system:
export async function createCheckoutForTrade(
trade: TradeForCheckout
): Promise<CheckoutResult> {
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,
},
});
return {
checkoutUrl: checkoutConfig.purchase_url as string,
checkoutId: checkoutConfig.id,
};
}
That single SDK call creates a hosted checkout page. The company_id points to the seller's connected account, the application_fee_amount is Swaphause's cut (9.5%), and the metadata carries trade IDs through to the webhook so I know which trade got paid.
The money flow: buyer pays → funds held → seller ships → platform authenticates the item → if it passes, payout releases to the seller's account; if it fails, the buyer gets a full refund and the seller's ask reopens.
Sellers also need identity verification before they can receive payouts. That's two SDK calls:
// Create a connected account under the platform
const company = await whopsdk.companies.create({
title: user.displayName || user.username,
parent_company_id: env.WHOP_COMPANY_ID,
email: user.email,
});
// Generate hosted KYC URL
const accountLink = await whopsdk.accountLinks.create({
company_id: company.id,
use_case: "account_onboarding",
redirect_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
Whop hosts the entire KYC flow, identity documents, bank account linking, tax info. Swaphause doesn't see or store any of it.
On the webhook side, Whop sends payment.succeeded and payment.failed events. The handler verifies the signature with whopsdk.webhooks.unwrap(), does an idempotency check (so duplicate deliveries don't create duplicate records), and routes each event to the right database update. About 60 lines for the handler plus two event-specific functions, all wrapped in a single Prisma $transaction.
Auth without a password table
Same pattern as my Substack clone. Whop OAuth with PKCE replaces all the password hashing, email verification, reset tokens, and session management. Two API routes and a config object. The login route generates a PKCE challenge, stashes the verifier in an iron-session cookie, and redirects to Whop. The callback exchanges the code for a token, pulls the user profile, upserts them into Postgres, and sets the session.
One thing worth calling out: the WHOP_API_BASE env var controls sandbox vs. production for the entire app. Set it to https://sandbox-api.whop.com during development, https://api.whop.com when you go live. One toggle, everything switches.
Chat without building chat
Building a real-time messaging system means WebSocket servers, message persistence, connection state, moderation. Whop has embeddable chat components, so I skipped all of that:
import { ChatElement, ChatSession, Elements } from "@whop/embedded-components-react-js";
import { loadWhopElements } from "@whop/embedded-components-vanilla-js";
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;
}
export function TradeChat({ channelId }: { channelId: string | null }) {
if (!channelId) return <div>Chat available once trade is matched.</div>;
return (
<Elements elements={elements}>
<ChatSession token={getToken}>
<ChatElement
options={{ channelId }}
style={{ height: "500px", width: "100%" }}
/>
</ChatSession>
</Elements>
);
}
Each trade gets a DM channel auto-created when a bid matches an ask. The matching engine calls Whop's DM channel API, stores the chatChannelId on the trade, and sends an initial system message. Buyers and sellers can coordinate shipping details without leaving the app. I wrote zero backend code for messaging.
The matching engine (the part I actually engineered)
This is probably the most interesting piece from a pure engineering standpoint. When a new bid comes in, the engine needs to find the lowest ask at or below the bid price and execute a trade. The tricky part: two concurrent bids could try to match the same ask.
The solution is a double-check-inside-transaction pattern:
// Fast path, check outside transaction
const matchingAsk = await prisma.ask.findFirst({
where: {
productSizeId: bid.productSizeId,
status: "ACTIVE",
price: { lte: bid.price },
},
orderBy: { price: "asc" },
});
if (!matchingAsk) return null;
// Safe path, re-validate inside transaction
const trade = await prisma.$transaction(async (tx) => {
const ask = await tx.ask.findFirst({
where: {
id: matchingAsk.id,
status: "ACTIVE",
},
});
if (!ask) return null;
const freshBid = await tx.bid.findUnique({
where: { id: bid.id, status: "ACTIVE" },
});
if (!freshBid) return null;
// Trade executes at the ask price (seller's price)
const tradePrice = ask.price;
const platformFee = tradePrice * (PLATFORM_FEE_PERCENT / 100);
// Mark both as matched, create trade, update product stats
await tx.bid.update({ where: { id: bid.id }, data: { status: "MATCHED" } });
await tx.ask.update({ where: { id: ask.id }, data: { status: "MATCHED" } });
return tx.trade.create({
data: {
buyerId: bid.userId,
sellerId: ask.userId,
productSizeId: bid.productSizeId,
bidId: bid.id,
askId: ask.id,
price: tradePrice,
platformFee,
status: "MATCHED",
},
});
});
Check outside the transaction first (fast path, avoids locking when there's no match), then re-validate inside the transaction (safe path, guarantees no two bids match the same ask). The trade always executes at the ask price, same as StockX.
Things I'd do differently
I'd build the webhook handler first. Same lesson I learned building the Substack clone. It's the most important code in the project because it's where payment state materializes in your database. If it's broken, people pay but your app doesn't know.
I'd start on the Whop sandbox from day one. I've done the "start on production then switch" dance before, and it always means recreating your app and re-entering all your credentials.
One thing specific to this project: Whop's sandbox doesn't deliver real webhook events. I ended up building a payment callback route as a fallback, after checkout, Whop redirects the buyer to a callback URL with a payment_id param, and the callback verifies payment status directly with the API. Build that from the start instead of debugging why your webhook never fires in sandbox.
Also: use the company API key, not the app API key. The company key has the permissions needed for company:create_child (creating connected accounts for sellers). I burned time on 403 errors before figuring that out.
Links
- Demo: stockx-clone-zeta.vercel.app
- Code: github.com/east-6/stockx-clone
- Full tutorial: 3-part walkthrough on the Whop blog covering every file from scaffold to deploy
- Whop developer docs: dev.whop.com
The Whop-specific code in the whole project is maybe 400 lines. Everything else is a normal Next.js app. If you're building a marketplace where sellers need to get paid and products need authentication before payout, the Whop developer docs are worth looking at.
Top comments (0)