npx create-next-app@latest mercor-clone
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mercorclone?schema=public"
# Whop
WHOP_API_KEY="whop_XXXXXXXXXXXXXXXX"
PLATFORM_COMPANY_ID="biz_XXXXXXXXXXXX"
WHOP_WEBHOOK_SECRET="whsec_XXXXXXXXXXX" # recommended for webhook signature verification
# Auth (example if using NextAuth)
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
pnpm add @whop/checkout @whop/checkout-react
# plus your web framework deps (e.g., Next.js, Prisma, etc.)
1) Project structure and pages
Create an App Router structure (Next.js) that mirrors the marketplace flows. Add recruiter pages for funding and managing gigs, earner pages for payouts and submissions, and an admin surface if needed. Folder structureapp/
layout.tsx
page.tsx
recruiter/
balance/page.tsx # Add funds UI (opens checkout embed)
my-listings/page.tsx
submissions/page.tsx # Approve/Complete gigs
layout.tsx
earner/
payouts/page.tsx # Setup payouts + payout portal
listings/page.tsx
my-submissions/page.tsx
layout.tsx
api/
checkout/
create-session/route.ts # Create Whop checkout configuration (payins)
whop/
events/route.ts # Webhook for payment events
payouts/
setup/route.ts # Create connected account company
submissions/
[id]/complete/route.ts # Create transfer payout on completion
"use client";
import { useState } from "react";
import AddFundsModal from "@/components/AddFundsModal";
export default function RecruiterBalancePage() {
const [open, setOpen] = useState(false);
return (
<main className="p-6">
<h1 className="text-2xl font-semibold">Balance</h1>
<p className="mt-2 text-gray-600">Add funds to pay for completed gigs.</p>
<button
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded"
onClick={() => setOpen(true)}
>
Add Funds
</button>
<AddFundsModal
isOpen={open}
onClose={() => setOpen(false)}
onSuccess={() => {
/* refetch balance */
}}
/>
</main>
);
}
"use client";
import { useState } from "react";
export default function EarnerPayoutsPage() {
const [loading, setLoading] = useState(false);
const [hasCompany, setHasCompany] = useState(false);
const [whopCompanyId, setWhopCompanyId] = useState<string | null>(null);
async function setupPayouts() {
setLoading(true);
try {
const res = await fetch("/api/payouts/setup", { method: "POST" });
if (res.ok) {
const data = await res.json();
setHasCompany(true);
setWhopCompanyId(data?.company?.whopId ?? null);
}
} finally {
setLoading(false);
}
}
return (
<main className="p-6">
<h1 className="text-2xl font-semibold">Payouts</h1>
{!hasCompany ? (
<button
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded"
onClick={setupPayouts}
disabled={loading}
>
{loading ? "Setting up..." : "Setup Payouts"}
</button>
) : (
<div className="mt-6">
{/* See section 3 for the payout portal embed pseudo-code */}
<p className="text-gray-600">Your payout account is ready.</p>
</div>
)}
</main>
);
}
2) Payment flow with Whop Checkout Embed
Server: create a Whop checkout configuration (one-time plan) that includes your own metadata to link payments back to the recruiter account.
// app/api/checkout/create-session/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { amount, currency = "USD" } = await request.json();
if (!amount || amount <= 0)
return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
const res = await fetch(
"https://api.whop.com/api/v1/checkout_configurations",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
plan: {
company_id: process.env.PLATFORM_COMPANY_ID,
visibility: "visible",
plan_type: "one_time",
release_method: "buy_now",
currency: currency.toLowerCase(),
initial_price: amount,
},
metadata: {
recruiterId: session.user.id,
amount: String(amount),
currency,
type: "add_funds",
},
}),
},
);
if (!res.ok)
return NextResponse.json(
{ error: "Failed to create checkout configuration" },
{ status: res.status },
);
const cfg = await res.json();
return NextResponse.json({
purchaseUrl: cfg.purchase_url,
checkoutConfigId: cfg.id,
planId: cfg.plan.id,
});
}
@whop/checkout-react.
"use client";
import { useState } from "react";
import { WhopCheckoutEmbed } from "@whop/checkout/react";
export default function AddFundsModal({
isOpen,
onClose,
onSuccess,
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
const [amount, setAmount] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [planId, setPlanId] = useState<string | null>(null);
const [checkoutConfigId, setCheckoutConfigId] = useState<string | null>(null);
const [error, setError] = useState("");
async function startCheckout() {
setIsCreating(true);
try {
const res = await fetch("/api/checkout/create-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: Number(amount), currency: "USD" }),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error ?? "Failed to create checkout");
return;
}
setPlanId(data.planId);
setCheckoutConfigId(data.checkoutConfigId);
} finally {
setIsCreating(false);
}
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center p-6 bg-black/30"
onClick={onClose}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-xl"
onClick={(e) => e.stopPropagation()}
>
{!planId || !checkoutConfigId ? (
<div>
<h2 className="text-xl font-semibold">Add Funds</h2>
<input
className="mt-4 w-full border rounded px-3 py-2"
type="number"
min="1"
step="0.01"
placeholder="100.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<div className="mt-4 flex gap-2">
<button className="px-3 py-2 border rounded" onClick={onClose}>
Cancel
</button>
<button
className="px-3 py-2 bg-indigo-600 text-white rounded"
onClick={startCheckout}
disabled={isCreating}
>
{isCreating ? "Creating..." : "Proceed to Checkout"}
</button>
</div>
</div>
) : (
<WhopCheckoutEmbed
planId={planId}
sessionId={checkoutConfigId}
returnUrl="https://yoursite.com/checkout/complete"
onComplete={() => {
onSuccess();
onClose();
}}
theme="system"
/>
)}
</div>
</div>
);
}
payment.succeeded.
// app/api/whop/events/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) {
// TODO: verify signature using WHOP_WEBHOOK_SECRET
const body = await request.json();
const { type: eventType, data } = body;
if (eventType?.startsWith("payment.")) {
const paymentId = data.id;
const recruiterId = data.metadata?.recruiterId;
const total = data.total ?? Number(data.metadata?.amount ?? 0);
const currency = (data.currency ?? "usd").toUpperCase();
if (!recruiterId) return NextResponse.json({ received: true });
const status =
eventType === "payment.succeeded"
? "succeeded"
: eventType === "payment.failed"
? "failed"
: "pending";
await prisma.payment.upsert({
where: { id: paymentId },
create: {
id: paymentId,
amount: total,
currency,
status,
metadata: data as any,
recruiterId,
},
update: { status, metadata: data as any },
});
if (
eventType === "payment.succeeded" &&
total > 0 &&
data.metadata?.type === "add_funds"
) {
const key = `payment_${paymentId}`;
const existing = await prisma.ledgerEntry.findUnique({
where: { idempotencyKey: key },
});
if (!existing) {
await prisma.ledgerEntry.create({
data: {
amount: total,
currency,
transactionType: "credit",
description: `Funds added via payment (${currency} ${total})`,
idempotencyKey: key,
recruiterId,
paymentId,
},
});
}
}
}
return NextResponse.json({ received: true });
}
3) Connected account payouts (company setup + payout portal)
Create a Whop sub-company (earner account) under your platform company. Store the returnedwhopId to route transfers.
// app/api/payouts/setup/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function POST() {
const session = await auth();
if (!session?.user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const existing = await prisma.whopCompany.findUnique({
where: { userId: session.user.id },
});
if (existing)
return NextResponse.json(
{ error: "Payout account already exists" },
{ status: 400 },
);
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { email: true, name: true },
});
const res = await fetch("https://api.whop.com/api/v1/companies", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: user!.email,
parent_company_id: process.env.PLATFORM_COMPANY_ID,
title: user!.name || user!.email.split("@")[0],
}),
});
if (!res.ok)
return NextResponse.json(
{ error: "Failed to create payout account" },
{ status: res.status },
);
const company = await res.json();
const record = await prisma.whopCompany.create({
data: {
title: company.title,
whopId: company.id,
metadata: company as any,
userId: session.user.id,
},
});
return NextResponse.json({
success: true,
company: { id: record.id, whopId: record.whopId, title: record.title },
});
}
- This shows the intended composition. Wire your real UI or SDK accordingly.
// Pseudo-code: conceptual payout portal composition
function EarnerPayoutPortal({ whopCompanyId }: { whopCompanyId: string }) {
return (
<PayoutSession companyId={whopCompanyId}>
<PayoutPortal
onClose={() => {
/* close modal */
}}
/>
</PayoutSession>
);
}
whopCompanyId and an idempotency key per submission.
// app/api/submissions/[id]/complete/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { createLedgerEntry, getRecruiterBalance } from "@/lib/ledger";
const PLATFORM_FEE_PERCENTAGE = 0.05;
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const submission = await prisma.submission.findUnique({
where: { id },
include: {
listing: { include: { recruiter: true } },
earner: { include: { whopCompany: true } },
},
});
if (!submission)
return NextResponse.json(
{ error: "Submission not found" },
{ status: 404 },
);
if (submission.listing.recruiterId !== session.user.id)
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (submission.status !== "Pending Delivery Review")
return NextResponse.json({ error: "Bad status" }, { status: 400 });
if (submission.transferId)
return NextResponse.json({ error: "Already paid" }, { status: 400 });
if (!submission.earner.whopCompany)
return NextResponse.json(
{ error: "Earner not set up for payouts" },
{ status: 400 },
);
const balance = await getRecruiterBalance(session.user.id);
const listingAmount = Number(submission.listing.amount);
if (balance < listingAmount)
return NextResponse.json(
{ error: "Insufficient balance" },
{ status: 400 },
);
const platformFee = listingAmount * PLATFORM_FEE_PERCENTAGE;
const earnerAmount = listingAmount - platformFee;
const idempotencyKey = `submission_${submission.id}_payout`;
const transfer = await fetch("https://api.whop.com/api/v1/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: earnerAmount,
currency: submission.listing.currency.toLowerCase(),
origin_id: process.env.PLATFORM_COMPANY_ID,
destination_id: submission.earner.whopCompany.whopId,
idempotence_key: idempotencyKey,
notes: `Gig payment: ${submission.listing.title}`,
metadata: {
submissionId: submission.id,
listingId: submission.listing.id,
},
}),
});
if (!transfer.ok)
return NextResponse.json(
{ error: "Failed to create transfer" },
{ status: transfer.status },
);
const t = await transfer.json();
const updated = await prisma.$transaction(async (tx) => {
await createLedgerEntry(
{
amount: listingAmount,
currency: submission.listing.currency,
transactionType: "debit",
description: `Payment for gig: ${submission.listing.title}`,
idempotencyKey: `${idempotencyKey}_debit`,
recruiterId: session.user!.id,
},
tx,
);
return tx.submission.update({
where: { id: submission.id },
data: { status: "Completed", transferId: t.id },
});
});
return NextResponse.json({
message: "Paid",
submission: updated,
transfer: { id: t.id, amount: earnerAmount },
});
}

