made it, here is it
---
name: whop-app
description: >
How to build, deploy, and publish Whop apps using Next.js (App Router or Pages Router),
with database options including Drizzle ORM Supabase or Convex, deployed on Vercel.
Use this skill whenever the user mentions Whop, Whop apps, Whop developer API, Whop
payments, in-app purchases, Whop webhooks, Whop websockets, Whop App Store, or wants
to build anything on the Whop platform. Also trigger for questions about charging users,
paying out users, verifying Whop access levels, experience IDs, or creator vs user
permissions. Always consult this skill before writing any Whop-related code — even for
small questions or single functions.
---
# Whop App Development Skill
## Overview
Whop is a creator platform with a developer API and App Store. Apps are Next.js projects
embedded inside Whop communities (called "experiences"). Each app installation gets a
unique `experienceId`. The platform provides:
- **Auth**: Token-based user verification per experience
- **Payments**: Charge users and pay out users/companies via the Whop API
- **WebSockets**: Real-time messaging scoped to an experience
- **Webhooks**: Server-to-server events (e.g. payment succeeded)
- **App Store**: Publish apps for creators to install on their communities
---
## 1. Project Setup
### Bootstrap
```bash
pnpx create-whop-app@latest my-app
cd my-app
pnpm dev # runs on localhost:3000
```
### Environment Variables
Copy from the [Whop Developer Dashboard](
whop.com/dashboard/developer) → your app → API Keys.
Create `.env.development.local`:
```env
WHOP_API_KEY=...
WHOP_APP_ID=...
NEXT_PUBLIC_WHOP_APP_ID=...
```
### Linking to Vercel (for env sync)
```bash
vercel link # link to existing Vercel project
vercel env pull .env.development.local # pull env vars locally
```
---
## 2. App Structure
### App Router (recommended)
```
app/
experiences/
[experienceId]/
page.tsx ← main app view (server component)
layout.tsx ← wraps with WhopWebsocketProvider
create/
page.tsx ← sub-pages scoped to experience
```
The `experienceId` comes from `params` in any route under `[experienceId]`.
### Pages Router
```
pages/
experiences/
[experienceId]/
index.tsx ← main app view
create.tsx ← sub-pages
api/
webhooks.ts ← webhook handler
```
In Pages Router, get `experienceId` from `useRouter().query.experienceId` on the client,
or from `context.params.experienceId` in `getServerSideProps`.
Auth in Pages Router uses API routes instead of server actions — call `verifyUserToken`
inside `getServerSideProps` or an API route handler, passing `req.headers`.
---
## 3. Auth & User Verification
### Pattern: `verifyUser` helper
Create `lib/authentication.ts`:
```typescript
import { cache } from "react";
import { headers } from "next/headers";
import { verifyUserToken } from "
@whop-apps/sdk";
import { WhopAPI } from "
@whop-apps/sdk";
export const verifyUser = cache(
async ({
experienceId,
requireAdmin,
}: {
experienceId: string;
requireAdmin?: boolean;
}) => {
const hdrs = await headers();
const { userId } = await verifyUserToken({ headers: hdrs });
const { accessLevel } = await
WhopAPI.me().GET("/me/access_passes/{experienceId}", {
params: { path: { experienceId } },
});
if (requireAdmin && accessLevel !== "admin") {
throw new Error("Admin required");
}
if (!requireAdmin && accessLevel === "no_access") {
throw new Error("No access");
}
return { userId, accessLevel };
}
);
```
- Use `cache()` so it's only evaluated once per request even if called multiple times
- `requireAdmin: true` → only admins pass; good for creator-only actions
- `requireAdmin` omitted → any user with access passes
### In server actions
```typescript
"use server";
export async function myAction(formData: FormData) {
const { userId, accessLevel } = await verifyUser({ experienceId, requireAdmin: true });
// proceed...
}
```
---
## 4. Database
Choose one approach based on your needs:
- **Drizzle Supabase** → SQL, familiar ORM, good for relational data and complex queries
- **Convex** → real-time reactive queries, no schema migrations, great for live-updating UIs
---
### Option A: Drizzle ORM Supabase
#### Install
```bash
pnpm add drizzle-orm postgres @paralleldrive/cuid2
pnpm add -D drizzle-kit
```
#### `drizzle.config.ts`
```typescript
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./drizzle",
driver: "pg",
dbCredentials: { connectionString: process.env.POSTGRES_URL! },
});
```
#### Schema pattern (`lib/db/schema.ts`)
```typescript
import { pgTable, text, integer, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";
const timestamps = {
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
};
export const items = pgTable("items", {
id: text("id").primaryKey().$defaultFn(() => createId()),
experienceId: text("experience_id").notNull(), // always scope to experience
createdByUserId: text("created_by_user_id").notNull(),
// ... your fields
...timestamps,
});
```
#### DB client (`lib/db/index.ts`)
```typescript
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(process.env.POSTGRES_URL!);
export const db = drizzle(client, { schema });
```
#### Setup Supabase via Vercel
1. Vercel → Settings → Integrations → Marketplace → Supabase
2. Create project, connect to your Vercel project (dev preview prod)
3. Run `vercel env pull` to get `POSTGRES_URL` locally
4. Add `"db:push": "drizzle-kit push"` to `package.json`
5. Run `pnpm db:push` to apply schema to DB
---
### Option B: Convex
Convex is a real-time backend — queries automatically re-run on the client when data changes, which pairs naturally with Whop's websocket model.
#### Install & init
```bash
pnpm add convex
pnpx convex dev # creates convex/ folder, links to Convex dashboard
```
#### Schema (`convex/schema.ts`)
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
items: defineTable({
experienceId: v.string(), // always scope to experience
createdByUserId: v.string(),
text: v.string(),
completedAt: v.optional(v.number()),
}).index("by_experience", ["experienceId"]),
});
```
#### Queries & Mutations (`convex/items.ts`)
```typescript
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const listByExperience = query({
args: { experienceId: v.string() },
handler: async (ctx, { experienceId }) => {
return ctx.db
.query("items")
.withIndex("by_experience", (q) => q.eq("experienceId", experienceId))
.collect();
},
});
export const create = mutation({
args: { experienceId: v.string(), text: v.string(), userId: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert("items", { ...args, createdByUserId: args.userId });
},
});
```
#### Using in Next.js App Router
```typescript
// app/layout.tsx — wrap with ConvexProvider
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(
process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function RootLayout({ children }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
// In a client component:
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
const items = useQuery(api.items.listByExperience, { experienceId });
// items auto-updates in real-time — no websocket wiring needed
```
#### Add Convex URL to env
```env
NEXT_PUBLIC_CONVEX_URL=
your-project.convex.cloud
```
#### Convex Whop Webhooks
In your Convex HTTP actions, you can receive webhook events:
```typescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/webhooks/whop",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.text();
// validate with Whop SDK, then call ctx.runMutation(...)
return new Response("OK", { status: 200 });
}),
});
export default http;
```
---
## 5. In-App Purchases (Charging Users)
### Flow overview
1. **Server**: Call `
WhopAPI.app().POST("/app/charge_user")` → returns `inAppPurchase` object
2. **Client**: Pass `inAppPurchase` to `inAppPurchase()` from `
@whop-apps/sdk` → opens Whop checkout modal
3. **Webhook**: Handle `payment.succeeded` server-to-server to record the payment
### Server action: create charge
```typescript
"use server";
import { WhopAPI } from "
@whop-apps/sdk";
export async function submitVote({ answerId, gameId, experienceId }: {...}) {
const { userId } = await verifyUser({ experienceId });
const charge = await
WhopAPI.app().POST("/app/charge_user", {
body: {
amount: game.answerCost,
currency: "usd",
userId,
metadata: { answerId, gameId }, // passed to webhook
},
});
return charge; // return to client
}
```
### Client: open checkout modal
```typescript
"use client";
import { inAppPurchase } from "
@whop-apps/sdk/client";
const chargeObj = await submitVote({ answerId, gameId, experienceId });
await inAppPurchase(chargeObj); // opens modal, resolves on success
```
### Paying out users
```typescript
await
WhopAPI.app().POST("/app/pay_user", {
body: {
amount: payoutAmount,
currency: "usd",
userId: winnerId,
ledgerAccountId: companyLedgerAccountId,
transferFee: ledgerAccount.transferFee,
idempotencyKey: `${userId}-${gameId}`, // prevents double-pay
note: "Game winnings",
},
});
```
### Paying out a company
```typescript
// Get companyId from experienceId
const exp = await
WhopAPI.app().GET("/app/experiences/{experienceId}", {
params: { path: { experienceId } },
});
const companyId =
exp.company.id;
await
WhopAPI.app().POST("/app/pay_company", {
body: {
amount: creatorCut,
currency: "usd",
companyId,
idempotencyKey: `company-${gameId}`,
},
});
```
**Always use `idempotencyKey`** — prevents accidental double-payments if the action runs twice.
---
## 6. Webhooks
### Setup in dashboard
1. Developer Dashboard → your app → Webhooks
2. Add endpoint: `
your-app.vercel.app/api/webh…`
3. Select events: `payment.succeeded` (and others as needed)
4. API version: **v5**
5. Copy the webhook secret → add to Vercel env as `WHOP_WEBHOOK_SECRET`
### Handler
**App Router** (`app/api/webhooks/route.ts`):
```typescript
import { validateWebhook } from "
@whop-apps/sdk";
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const body = await req.text();
const event = await validateWebhook(body, req.headers, process.env.WHOP_WEBHOOK_SECRET!);
// throws if invalid — only
whop.com can pass this check
if (event.type === "payment.succeeded") {
const { finalAmount, amountAfterFees, currency, userId, metadata } =
event.data;
const { answerId, gameId } = metadata as { answerId: string; gameId: string };
if (!answerId || !gameId || !userId || !amountAfterFees || currency !== "usd") {
return new Response("Invalid", { status: 400 });
}
waitUntil(async () => {
await db.insert(votes).values({ gameId, answerId, userId, amount: finalAmount, amountAfterFees });
await sendUpdate(gameId, experienceId);
});
}
return new Response("OK", { status: 200 });
}
```
**Pages Router** (`pages/api/webhooks.ts`):
```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { validateWebhook } from "
@whop-apps/sdk";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const body = await getRawBody(req); // use 'raw-body' package
const event = await validateWebhook(body.toString(), req.headers, process.env.WHOP_WEBHOOK_SECRET!);
if (event.type === "payment.succeeded") {
// handle same as above, but no waitUntil — just await inline
await handlePayment(
event.data);
}
res.status(200).end();
}
export const config = { api: { bodyParser: false } }; // required for raw body
```
- `validateWebhook` ensures only Whop can send events
- `waitUntil` runs async work after the 200 response → prevents webhook retries
- Filter events by checking `metadata` fields from your charge call
---
## 7. WebSockets (Real-time)
> **Note**: If using Convex, you get real-time reactivity for free via `useQuery` — you may not need Whop WebSockets for data sync. Use Whop WebSockets when you need to push state to all connected viewers beyond what your DB query covers (e.g. game phase transitions, push notifications).
### Layout: join the experience channel (App Router)
```typescript
// app/experiences/[experienceId]/layout.tsx
import { WhopWebsocketProvider } from "
@whop-apps/sdk/client";
export default function Layout({ children, params }) {
return (
<WhopWebsocketProvider experienceId={params.experienceId}>
{children}
</WhopWebsocketProvider>
);
}
```
For **Pages Router**, wrap your `_app.tsx` or individual page components with `WhopWebsocketProvider`, passing `experienceId` from router query.
### Server: send a message to all viewers
```typescript
import { WhopAPI } from "
@whop-apps/sdk";
async function sendUpdate(gameId: string, experienceId: string) {
const game = await loadGame(gameId);
if (!game) return;
await
WhopAPI.app().POST("/app/experiences/{experienceId}/send_websocket_message", {
params: { path: { experienceId } },
body: { message: JSON.stringify(game) },
});
}
```
Server-sent messages are **trusted** (sent with your API key).
### Client: receive messages
```typescript
"use client";
import { useOnWebsocketMessage } from "
@whop-apps/sdk/client";
useOnWebsocketMessage((message) => {
if (!message.isTrusted) return; // ignore client-broadcast messages
const updatedGame = JSON.parse(
message.data) as GameWithVotes;
setGame((prev) => ({ ...updatedGame, userVoteAnswerId: prev.userVoteAnswerId }));
// merge in user-specific state that server broadcast can't include
});
```
### Client: broadcast (un-trusted, fast)
```typescript
import { useBroadcastWebsocketMessage } from "
@whop-apps/sdk/client";
const broadcast = useBroadcastWebsocketMessage();
broadcast({ type: "cursor", x: 100, y: 200 }); // visible to all, not verified
```
Use trusted (server) messages for game state. Use broadcast for ephemeral UI like cursors.
---
## 8. Permissions
Set up in Developer Dashboard → your app → Permissions tab.
- Request only permissions your app needs (each endpoint documents its required permissions)
- Mark permissions as **required** or **optional** (creators can toggle optional ones off)
- After adding permissions, visit `
whop.com/apps/app_xxx/instal…` to re-approve on your own company
- You can request up to 100 permissions
---
## 9. Deploying to Vercel
```bash
# 1. Push code to GitHub
git add . && git commit -m "init" && git push
# 2. Create Vercel project, import GitHub repo
# 3. Add environment variables in Vercel dashboard (same as .env.development.local)
# 4. Deploy
# 5. Set production domain in Whop dashboard
# Developer Dashboard → your app → Base URL → paste Vercel URL (https://...)
```
After setting the base URL, switch from "localhost" to "production" in the Whop community settings icon.
---
## 10. Publishing to the App Store
1. Developer Dashboard → your app → App Details
2. Fill in: **name**, **description**, **icon**, **category**
3. Enable creator DM automation (optional — sends DM when creator installs)
4. Hit **Publish** → app enters review → appears on App Store
---
## Key SDK Imports Reference
```typescript
// Server
import { WhopAPI, validateWebhook, verifyUserToken } from "
@whop-apps/sdk";
// Client
import { inAppPurchase, useOnWebsocketMessage, useBroadcastWebsocketMessage, WhopWebsocketProvider } from "
@whop-apps/sdk/client";
// UI components (Frosted UI, built on Radix)
import { Button, Text } from "
@whop-apps/react/components";
// Full component docs:
storybook.whop.com
```
---
## Common Patterns & Gotchas
- **Admin vs User separation**: Admins progress state; users pay/vote. Enforce with `requireAdmin` in `verifyUser`.
- **Idempotency keys**: Always set on `pay_user` and `pay_company` to prevent double payments.
- **Merge websocket state**: Server broadcasts can't include per-user data. Merge on client: `{ ...serverUpdate, userVoteAnswerId: prev.userVoteAnswerId }`.
- **`waitUntil` in webhooks** (App Router): Run DB writes after returning 200 to avoid webhook retries. Pages Router: just `await` inline.
- **Pages Router webhook body**: Must disable `bodyParser` and use `raw-body` to read the raw request body for `validateWebhook`.
- **Experience ID scoping**: Always scope DB queries to `experienceId` — same app can be installed multiple times in different communities.
- **`cache()` on verifyUser**: Prevents duplicate auth calls within the same request (App Router only).
- **Minimum payout**: $1 USD minimum. Check `amount >= 100` (cents) before calling `pay_user`.
- **Fees**: Use `amountAfterFees` from the webhook (not `finalAmount`) for accurate accounting and payouts.
- **Convex Whop WS**: With Convex, prefer `useQuery` for data sync; use Whop WebSockets only for non-data events like push notifications or game phase transitions.
- **Convex HTTP actions**: Deploy to `
your-project.convex.site` — use this as your webhook endpoint URL in the Whop dashboard.