Skip to main content
This guide shows how to recreate the core of the Mercor Clone demo using Whop Rails. You’ll build the key pages, wire up payins via the Whop Checkout Embed, and enable earner payouts as sub-merchants. To see the example repo, visit: github.com/whopio/mercor-clone. Run the following command to create a new Next.js app:
npx create-next-app@latest mercor-clone
Environment variables
# 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)"
Install deps
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: recruiter pages for funding and managing gigs, earner pages for payouts and submissions, and an admin surface if needed. Folder structure
app/
  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 sub-merchant company
    submissions/
      [id]/complete/route.ts    # Create transfer payout on completion
Example minimal recruiter balance page
"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>
  );
}
Example minimal earner payouts page
"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) Payin 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,
  });
}
Client: open an “Add Funds” modal and embed the Whop checkout with @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}
            onComplete={() => {
              onSuccess();
              onClose();
            }}
            theme="system"
          />
        )}
      </div>
    </div>
  );
}
Webhook: receive Whop payment events and credit recruiter balance on 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) Sub-merchant payouts (company setup + payout portal)

Create a Whop sub-company (earner account) under your platform company; store the returned whopId 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 },
  });
}
Embed a payout portal (pseudo-code)
  • 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>
  );
}
Gig completion: transfer funds from platform to earner sub-company using the earner’s 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 },
  });
}
That’s the core of the Mercor-style marketplace: recruiters fund balances (payins), earners set up payout accounts (sub-companies), and completing gigs transfers money to earners minus your platform fee. Repo for reference: whopio/mercor-clone