Launch a virtual card program on your own platform. Your customers get cards that spend their own balance, with limits you set and controls you keep. Learn how in this guide.

Key takeaways

  • A platform issues cards against each customer's own account, so a card spends that customer's balance and never the platform's.
  • One request both issues a card and applies for the right to issue one, and the response tells you which happened.
  • Approval attaches to the customer's account, so one approval covers every card that customer will ever need.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

You can offer virtual cards to your customers, control how much they can spend, and freeze them if need be using the Whop virtual card issuing API. You don't even have to talk to a bank or be a card issuer to do this.

A Whop card spends from the balance of the account it belongs to, so you don't have to create a card owner record or set up a card program. Cards on Whop are just a single call that defines the user and the limit of the card.

If you're building a neobank, a creator app, or any platform where your customers already hold money with you, this is how you hand each of them a card.

In this tutorial, we're going to check where a customer's account stands, send them off to finish their own verification, issue against their balance once approval lands, show them their card number, and freeze or cancel it on request.

You can see a preview of the entire walkthrough in our companion demo.

Prerequisites

In this tutorial, we're going to work on a Next.js project with lib/ and app/api/ folders, which we're going to add files into.

Every customer you issue a card to needs their own account on Whop, linked to your platform. Whop calls that a connected account, and it's the same one they'd receive payouts into. Our global payouts guide covers creating one.

This tutorial starts from the point where a customer already has theirs.

Customers get approved, not your platform

When it comes to the approval gate, your platform doesn't have to create an application, the only gate sits on each customer.

Their connected account has to pass an identity check as a person or as a business before it can hold card. We start that check for them later down the guide.

Whop cards run on Visa and spend anywhere Visa is accepted. Whop keeps the current restrictions on its prohibitions page.

Cards only exist in production

We use Whop Sandbox in most of our tutorials but not this one, because cards only exist in production.

Every account you issue against is a real, verified one with money in it, so the first card you issue can really spend.

Get an API key

The first thing you should do before starting to issue virtual cards is to get an API key. To do this, go to the developer page of your business's dashboard and then, under the company API keys section, click the create button.

Then give your API key a name and give it these permissions:

  • payout:account:read
  • payout:account:update
  • company:balance:read
  • webhook_receive:card_applications
  • webhook_receive:card_transactions
company:balance:read is granted to the owner and advertiser roles, not to admin. A key made by an admin silently lacks it, and card updates fail later for no visible reason.

Install the packages

Now let's run the command below to install the packages we are going to use:

Terminal
npm install zod @whop/sdk

zod confirms the inputs we get are in the format we expect. That's the only package we need, because we talk to Whop through the file we create in the next step rather than through an SDK, @whop/sdk is here for one function: the helper that checks a webhook signature in the last section.

Environment variables

Now create a .env.local file in the project root with these environment variables:

VariableExampleWhere it comes from
WHOP_COMPANY_API_KEYapik_...The key we just made
WHOP_COMPANY_IDbiz_...Your own platform's business ID, found in the dashboard URL
WHOP_API_VERSION_DATE2026-08-05-1Typed by hand, the API version this was written against
WHOP_WEBHOOK_SECRETws_...Shown when we create the webhook, in the last section
WHOP_SANDBOXfalseTyped by hand. Leave it off or set false, cards need production

Check the environment variables on startup

We want to check all the environment variables at startup to avoid silent errors, so let's go to the lib/ folder and create env.ts file with the content:

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

const schema = z.object({
  WHOP_COMPANY_API_KEY: z.string().min(1, "WHOP_COMPANY_API_KEY is missing"),
  WHOP_COMPANY_ID: z
    .string()
    .startsWith("biz_", "WHOP_COMPANY_ID must start with biz_"),
  // The card endpoints are beta and can change without notice, so every request
  // pins the version this was written against. Leaving the header off pins to
  // the 2025-01-01 shapes instead of to the current ones.
  WHOP_API_VERSION_DATE: z.string().min(1).default("2026-08-05-1"),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((value) => value === "true"),
  WHOP_WEBHOOK_SECRET: z.string().default(""),
});

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

let cached: Env | undefined;

export function getEnv(): Env {
  if (cached) return cached;

  // An unset line in a .env file arrives as an empty string, not undefined,
  // so .optional() alone would not save us. Strip the blanks first.
  const raw: Record<string, string | undefined> = { ...process.env };
  for (const key of Object.keys(raw)) {
    if (raw[key] === "") delete raw[key];
  }

  const parsed = schema.safeParse(raw);

  if (!parsed.success) {
    const problems = parsed.error.issues
      .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
      .join("\n");
    throw new Error(`Environment is not configured.\n${problems}`);
  }

  cached = parsed.data;
  return cached;
}

Create the client

Now let's create the file that defines how we talk to Whop, select the right API address, and make the errors we get readable.

Go to the lib/ folder and then create a file called whop.ts with the content:

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

export function apiBaseUrl(): string {
  return getEnv().WHOP_SANDBOX
    ? "https://sandbox-api.whop.com/api/v1"
    : "https://api.whop.com/api/v1";
}

export class WhopError extends Error {
  constructor(
    readonly status: number,
    message: string,
    readonly type?: string,
  ) {
    super(message);
    this.name = "WhopError";
  }
}

// Every card call goes through here, so the key and the version header are
// set in exactly one place.
export async function whopFetch<T>(
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const env = getEnv();

  const response = await fetch(`${apiBaseUrl()}${path}`, {
    ...init,
    cache: "no-store",
    headers: {
      Authorization: `Bearer ${env.WHOP_COMPANY_API_KEY}`,
      "Api-Version-Date": env.WHOP_API_VERSION_DATE,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });

  const body: unknown = await response.json().catch(() => null);

  if (!response.ok) {
    throw new WhopError(response.status, whopMessage(body), whopType(body));
  }

  return body as T;
}

// The card endpoints send only type and message, while the stable ones also
// send code and param. Treat the extra two as optional.
export function whopMessage(body: unknown): string {
  if (body && typeof body === "object" && "error" in body) {
    const error = (body as { error: unknown }).error;
    if (error && typeof error === "object" && "message" in error) {
      const message = (error as { message: unknown }).message;
      if (typeof message === "string") return message;
    }
  }
  return "Whop did not explain what went wrong.";
}

function whopType(body: unknown): string | undefined {
  if (body && typeof body === "object" && "error" in body) {
    const error = (body as { error: unknown }).error;
    if (error && typeof error === "object" && "type" in error) {
      const type = (error as { type: unknown }).type;
      if (typeof type === "string") return type;
    }
  }
  return undefined;
}

Every route we write creates something real on a customer's account, so each one goes through a rate limit first. Still in lib/, create a file called rate-limit.ts with the content:

rate-limit.ts
const hits = new Map<string, { count: number; resetAt: number }>();

export function checkRateLimit(ip: string, limit = 10, windowMs = 60_000): boolean {
  const now = Date.now();
  const entry = hits.get(ip);

  if (!entry || now > entry.resetAt) {
    hits.set(ip, { count: 1, resetAt: now + windowMs });
    return true;
  }

  if (entry.count >= limit) return false;

  entry.count += 1;
  return true;
}

export function clientIp(request: Request): string {
  return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "local";
}

How card issuing works

Four things matter: the account, its balance, the application, and the card. The account is the customer, and its ID starts with biz_.

The balance is what that account holds, and the card spends straight from it. The application is the permission to issue at all, and the card is always assigned to a member of the account it spends from.

How a customer gets their first card

Every customer takes the same path. We link their account, they pass their identity check, they're approved, and then we issue the card.

There's no separate endpoint to apply with. POST /cards is both calls in one: the first time we send it for a customer it files their application, and once they're approved the same call issues the card.

Cards arrive on their own only in rare cases, so nearly every customer starts with that application.

We can't pass the identity check for a customer, but we can start it for them. POST /verifications gives us a link to hand over, and our KYC guide covers what they see when they open it.

As we wait for approval, we listen to card_application.approved rather than polling for it.

The application belongs to accounts, not cards, so an approved account is the thing that carries the permission.

Check where a customer's account stands

Now, with a single read, we can see if the card can be issued or not.

This one call tells us everything we need to know: whether the account can issue cards, which stage the application is at, and how much money can be spent with that card. The field that decides is capabilities.card_issuing.

Go to lib/cards/ and create a file called account.ts with the content:

account.ts
import { z } from "zod";
import { whopFetch } from "@/lib/whop";

const accountSchema = z.object({
  id: z.string(),
  title: z.string().nullish(),
  country: z.string().nullish(),
  capabilities: z
    .object({ card_issuing: z.string().nullish() })
    .loose()
    .nullish(),
  cards: z
    .object({
      kind: z.string().nullish(),
      status: z.string().nullish(),
    })
    .nullish(),
  verification: z
    .object({
      individual: z.object({ status: z.string().nullish() }).nullish(),
      business: z.object({ status: z.string().nullish() }).nullish(),
    })
    .nullish(),
  balances: z
    .array(
      z.object({
        symbol: z.string(),
        // Balances arrive as decimal strings, never numbers. Parse once here so
        // nothing downstream has to guess.
        balance: z.string(),
        breakdown: z
          .object({
            available: z.string().nullish(),
            pending: z.string().nullish(),
            reserve: z.string().nullish(),
          })
          .nullish(),
      }),
    )
    .nullish(),
});

export type CardReadiness = {
  accountId: string;
  title: string | null;
  cardIssuing: string;
  application: { kind: string | null; status: string } | null;
  verification: { individual: string | null; business: string | null };
  balance: { available: number; pending: number; total: number };
  canIssue: boolean;
};

export async function readCardReadiness(accountId: string): Promise<CardReadiness> {
  const raw = await whopFetch<unknown>(`/accounts/${accountId}`);
  const account = accountSchema.parse(raw);

  const usd =
    account.balances?.find((entry) => entry.symbol === "USD") ??
    account.balances?.[0];

  const available = Number(usd?.breakdown?.available ?? "0");
  const pending = Number(usd?.breakdown?.pending ?? "0");

  return {
    accountId: account.id,
    title: account.title ?? null,
    cardIssuing: account.capabilities?.card_issuing ?? "unknown",
    application: account.cards
      ? {
          kind: account.cards.kind ?? null,
          status: account.cards.status ?? "unknown",
        }
      : null,
    verification: {
      individual: account.verification?.individual?.status ?? null,
      business: account.verification?.business?.status ?? null,
    },
    balance: {
      available,
      pending,
      // A card spends the pending part as well as the available part, so the
      // number that matters to a cardholder is the sum.
      total: available + pending,
    },
    canIssue: account.capabilities?.card_issuing === "active",
  };
}

export function formatUsd(amount: number): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(amount);
}

Send a customer through their identity check

Only accounts that have passed an identity check can hold a card, and we can't pass it on the customer's behalf. What we can do instead is open the check for them and hand them the KYC/KYB link.

We do that with a single call, which gives back a session_url. A page hosted by Whop where the customer answers the questions and uploads what to ask for. The link is good for seven days so it's better to ask for it when the customer is ready to use it than to store one and wait for them to complete it.

In some cases the reviewer can come back wanting more, like a document or a field that was missing. Those arrive as requested_information and each one carries a label written for the customer, so you should display that to the user.

Go to lib/cards/ and create a file called verifications.ts with the content:

verifications.ts
import { z } from "zod";
import { whopFetch } from "@/lib/whop";

const verificationSchema = z.object({
  id: z.string(),
  status: z.string().nullish(),
  session_url: z.string().nullish(),
  requested_information: z
    .array(
      z.object({
        id: z.string(),
        label: z.string().nullish(),
        requirement: z.string().nullish(),
      }),
    )
    .nullish(),
});

export type Verification = {
  id: string;
  status: string;
  sessionUrl: string | null;
  outstanding: { id: string; label: string }[];
};

export async function startVerification(
  accountId: string,
): Promise<Verification> {
  const parsed = verificationSchema.parse(
    await whopFetch<unknown>(`/verifications?account_id=${accountId}`, {
      method: "POST",
      body: "{}",
    }),
  );

  return {
    id: parsed.id,
    status: parsed.status ?? "unknown",
    sessionUrl: parsed.session_url ?? null,
    outstanding: (parsed.requested_information ?? []).map((item) => ({
      id: item.id,
      label: item.label ?? item.requirement ?? "More information needed",
    })),
  };
}

Calling this twice is safe. Whop hands back the check already in progress instead of starting a second one.

Ask for a customer's card

When we ask for a card, one of two things usually happens:

  • If the account is already approved to issue cards, we get the card itself.
  • If the application isn't made yet, the same call creates the application and then gives it back to us instead of the card.

Which one we got is written in the object field of the response, so check that before reading anything else.

Let's do the card itself first because all calls that return a card share this. Go to lib/cards/ and create a file called card.ts with the content:

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

export const cardSchema = z.object({
  object: z.literal("card"),
  id: z.string(),
  name: z.string().nullish(),
  type: z.string().nullish(),
  status: z.string().nullish(),
  last4: z.string().nullish(),
  expiration_month: z.string().nullish(),
  expiration_year: z.string().nullish(),
  user_id: z.string().nullish(),
  // Two different units on the same object. limit.amount is dollars and
  // spent_last_month is cents, so read both through the helpers below and
  // never through a bare number.
  spent_last_month: z.number().nullish(),
  limit: z
    .object({
      amount: z.number().nullish(),
      frequency: z.string().nullish(),
    })
    .nullish(),
  created_at: z.string().nullish(),
  canceled_at: z.string().nullish(),
});

export type Card = z.infer<typeof cardSchema>;

/** limit.amount is already dollars. */
export function limitInDollars(card: Card): number | null {
  return card.limit?.amount ?? null;
}

/** spent_last_month is cents, on the same object that reports the limit in dollars. */
export function spentInDollars(card: Card): number {
  return (card.spent_last_month ?? 0) / 100;
}

export function frequencyLabel(card: Card): string {
  const frequency = card.limit?.frequency;
  if (!frequency) return "no cap";

  const labels: Record<string, string> = {
    daily: "per day",
    weekly: "per week",
    monthly: "per month",
    one_time: "once",
    per_transaction: "per transaction",
  };

  return labels[frequency] ?? frequency;
}

Now let's create the call itself. Go to lib/cards/ and create a file called create.ts with the content:

create.ts
import { z } from "zod";
import { whopFetch, WhopError } from "@/lib/whop";
import { cardSchema, type Card } from "@/lib/cards/card";

const applicationSchema = z.object({
  object: z.literal("card_application"),
  id: z.string().nullish(),
  status: z.string().nullish(),
  hosted_url: z.string().nullish(),
});

const provisioningSchema = z.object({
  object: z.literal("card_provisioning"),
  provisioning_job_id: z.string().nullish(),
});

const invitationSchema = z.object({
  object: z.literal("card_invitation"),
  invitation_sent: z.boolean().nullish(),
});

const createResponseSchema = z.discriminatedUnion("object", [
  cardSchema,
  applicationSchema,
  provisioningSchema,
  invitationSchema,
]);

export type CreateCardResult =
  | { kind: "card"; card: Card }
  | { kind: "application"; id: string | null; status: string; hostedUrl: string | null }
  | { kind: "provisioning"; jobId: string | null }
  | { kind: "invitation" }
  | { kind: "blocked"; reason: BlockedReason; message: string };

export type BlockedReason =
  | "never_applied"
  | "application_pending"
  | "application_exists"
  | "needs_verification"
  | "needs_assignee"
  | "assignee_not_a_member"
  | "forbidden"
  | "unknown";

export type IssueCardInput = {
  accountId: string;
  assignedUserId: string;
  name: string;
  idempotencyKey: string;
  spendLimit?: number;
  spendLimitFrequency?: "daily" | "weekly" | "monthly" | "one_time";
};

export async function issueCard(input: IssueCardInput): Promise<CreateCardResult> {
  const body = {
    account_id: input.accountId,
    assigned_user_id: input.assignedUserId,
    name: input.name,
    ...(input.spendLimit ? { spend_limit: input.spendLimit } : {}),
    ...(input.spendLimitFrequency
      ? { spend_limit_frequency: input.spendLimitFrequency }
      : {}),
  };

  const payload = JSON.stringify(body);

  try {
    const raw = await whopFetch<unknown>("/cards", {
      method: "POST",
      headers: {
        "Idempotency-Key": input.idempotencyKey,
      },
      body: payload,
    });

    const parsed = createResponseSchema.parse(raw);

    switch (parsed.object) {
      case "card":
        return { kind: "card", card: parsed };
      case "card_application":
        return {
          kind: "application",
          id: parsed.id ?? null,
          status: parsed.status ?? "unknown",
          hostedUrl: parsed.hosted_url ?? null,
        };
      case "card_provisioning":
        return { kind: "provisioning", jobId: parsed.provisioning_job_id ?? null };
      case "card_invitation":
        return { kind: "invitation" };
    }
  } catch (error: unknown) {
    if (error instanceof WhopError) {
      return { kind: "blocked", reason: classify(error.message), message: error.message };
    }
    throw error;
  }
}

export function classify(message: string): BlockedReason {
  const text = message.toLowerCase();

  if (text.includes("no rain account found")) return "never_applied";
  if (text.includes("rain account is not approved")) return "application_pending";
  if (text.includes("already has a card application")) return "application_exists";
  if (text.includes("no approved identity verification")) return "needs_verification";
  if (text.includes("assigned_user_id is required")) return "needs_assignee";
  if (text.includes("must be a member of this account")) return "assignee_not_a_member";
  if (text.includes("not authorized")) return "forbidden";

  return "unknown";
}

/** What to tell a person, rather than what the API said. */
export const BLOCKED_COPY: Record<BlockedReason, string> = {
  never_applied:
    "This account has never applied to issue cards. The same request that makes a card files that application.",
  application_pending:
    "The application is in and waiting on approval. Nothing more to send, the card appears once it clears.",
  application_exists:
    "An application is already on file for this account, so a second one cannot be filed.",
  needs_verification:
    "Whoever the card is for has to finish their identity check on Whop first.",
  needs_assignee:
    "A card needs a person. Pick who on that account it belongs to.",
  assignee_not_a_member:
    "That person does not belong to the account the card would spend from.",
  forbidden: "This API key cannot act on that account.",
  unknown: "Whop refused the request.",
};

The object field decides what we do at this point. A card means we're all good, and an application means the customer has not been cleared yet, so we send them through the identity check above and wait for the approval webhook.

The card is assigned to a person, and assignedUserId is their Whop user ID, the one starting with user_. For a platform that's the customer who owns the account the card spends from.

Now, let's connect to a route. Go to app/api/issue/ and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { issueCard } from "@/lib/cards/create";
import { checkRateLimit, clientIp } from "@/lib/rate-limit";

const body = z.object({
  accountId: z.string().startsWith("biz_"),
  assignedUserId: z.string().startsWith("user_"),
  name: z.string().min(1).max(40),
  spendLimit: z.number().positive().max(100_000).optional(),
  spendLimitFrequency: z
    .enum(["daily", "weekly", "monthly", "one_time"])
    .optional(),
});

export async function POST(request: Request) {
  if (!checkRateLimit(clientIp(request), 5)) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  const parsed = body.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }
  return NextResponse.json(
    await issueCard({
      ...parsed.data,
      idempotencyKey: crypto.randomUUID(),
    }),
  );
}

Show the message Whop sends back rather than a generic failure. Rain is the card issuer behind Whop cards, so its name appears in these, and each one names which check that customer's account has not passed yet.

Set what a customer's card can spend

Cards without a limit can spend the entire balance, so spend_limit and spend_limit_frequency are worth setting on every one. 500 with monthly means a monthly limit of $500.

When the card returns, there is one thing you should keep in mind: the limits come back as dollars, but spends come back as cents. So a 500 limit means $500, but 12500 spent is $125. So we don't mix the two up, lib/cards/card.ts has two helper functions.

To see the cards, go to app/api/cards/ and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { whopFetch, whopMessage, WhopError } from "@/lib/whop";
import { cardSchema } from "@/lib/cards/card";
import { classify } from "@/lib/cards/create";

export const dynamic = "force-dynamic";

// GET /cards has no pagination at all. Its only parameters are the owner, so
// there is no loop to write here.
const listSchema = z.object({ data: z.array(cardSchema) });

export async function GET(request: Request) {
  const accountId = new URL(request.url).searchParams.get("accountId");

  if (!accountId?.startsWith("biz_")) {
    return NextResponse.json({ error: "accountId is required" }, { status: 400 });
  }

  try {
    const page = listSchema.parse(
      await whopFetch<unknown>(`/cards?account_id=${accountId}`),
    );
    return NextResponse.json({ cards: page.data });
  } catch (error: unknown) {
    if (error instanceof WhopError) {
      // An account that cannot issue yet answers here too, and the reason is
      // worth showing rather than swallowing.
      return NextResponse.json({
        cards: [],
        blocked: { reason: classify(error.message), message: error.message },
      });
    }
    console.error("cards_list_failed", error);
    return NextResponse.json({ error: whopMessage(error) }, { status: 502 });
  }
}

This route returns the cards for one account, so make sure only that customer can reach it. Our guide on user authentication covers this fully.

Show a customer their card number

When we fetch a card, we get the card number and the CVC as plain text. What we need to do is to display that information only on the user screen. Nowhere else:

  • A database, even if it's encrypted
  • A log line, including a console.log that has the whole card in it
  • A prop from a server component into a client component, because Next.js embeds that into the page's HTML
  • Analytics events, error reports, or URLs

Now go to lib/cards/ and then create a file called secrets.ts with the content:

secrets.ts
import { z } from "zod";
import { whopFetch } from "@/lib/whop";

// These are present only on this call, and only while the card is active.
const secretsSchema = z.object({
  secrets: z
    .object({
      card_number: z.string().nullish(),
      cvc: z.string().nullish(),
      name_on_card: z.string().nullish(),
    })
    .nullish(),
  expiration_month: z.string().nullish(),
  expiration_year: z.string().nullish(),
  status: z.string().nullish(),
});

// The return type is written out on purpose. Nothing else from the response can
// travel with it, so nothing extra can leak by accident.
export type RevealedCard = {
  number: string;
  cvc: string;
  expiryMonth: string;
  expiryYear: string;
};

export async function revealCard(
  cardId: string,
  accountId: string,
): Promise<RevealedCard | null> {
  const card = secretsSchema.parse(
    await whopFetch<unknown>(`/cards/${cardId}?account_id=${accountId}`),
  );

  if (!card.secrets?.card_number || !card.secrets.cvc) return null;

  return {
    number: card.secrets.card_number,
    cvc: card.secrets.cvc,
    expiryMonth: card.expiration_month ?? "",
    expiryYear: card.expiration_year ?? "",
  };
}

And then create the screen that shows them. Go to components/ and create a file called CardDetails.tsx with the content:

CardDetails.tsx
"use client";

import { useEffect, useState } from "react";

type Revealed = {
  number: string;
  cvc: string;
  expiryMonth: string;
  expiryYear: string;
};

export function CardDetails({
  cardId,
  last4,
}: {
  cardId: string;
  last4: string | null;
}) {
  const [details, setDetails] = useState<Revealed | null>(null);
  const [busy, setBusy] = useState(false);

  // Clearing after thirty seconds bounds how long the number sits on an
  // unattended screen. It is not a security boundary.
  useEffect(() => {
    if (!details) return;
    const timer = setTimeout(() => setDetails(null), 30_000);
    return () => clearTimeout(timer);
  }, [details]);

  async function reveal() {
    setBusy(true);
    try {
      const response = await fetch(`/api/cards/${cardId}`);
      if (response.ok) setDetails(await response.json());
    } finally {
      setBusy(false);
    }
  }

  if (!details) {
    return (
      <button onClick={reveal} disabled={busy}>
        {busy ? "Loading" : `Show the number for card ending ${last4 ?? "----"}`}
      </button>
    );
  }

  return (
    <div>
      <div>{details.number}</div>
      <div>
        {details.expiryMonth}/{details.expiryYear} CVC {details.cvc}
      </div>
      <button onClick={() => setDetails(null)}>Hide</button>
    </div>
  );
}

This route hands a live card number back to us, so it can't take the account from the URL. It has to work out who is asking.

Go to lib/ and create a file called session.ts with the content:

session.ts
import { cookies } from "next/headers";

export async function currentAccountId(): Promise<string | null> {
  const value = (await cookies()).get("whop_account_id")?.value ?? null;
  return value?.startsWith("biz_") ? value : null;
}
The cookie is usable for the browser, which is enough for development, but not for production because a cookie the browser can set means a cookie the browser can manipulate.

Our guide on user authentication replaces it with a signed session, and it's the one piece of this you shouldn't ship as it stands.

Freeze or cancel a customer's card

When you freeze a card, it can be reverted, and it's the right thing to do when you see something that's not right. When you cancel a card, it can't be brought back.

Now go to app/api/cards/[cardId]/ and create a file called route.ts with the content:

route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { whopFetch, whopMessage } from "@/lib/whop";
import { cardSchema } from "@/lib/cards/card";
import { revealCard } from "@/lib/cards/secrets";
import { currentAccountId } from "@/lib/session";
import { checkRateLimit, clientIp } from "@/lib/rate-limit";

export const dynamic = "force-dynamic";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ cardId: string }> },
) {
  if (!checkRateLimit(clientIp(request), 5)) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  const accountId = await currentAccountId();
  if (!accountId) {
    return NextResponse.json({ error: "Not signed in" }, { status: 401 });
  }
  const { cardId } = await params;

  try {
    const revealed = await revealCard(cardId, accountId);
    if (!revealed) {
      return NextResponse.json({ error: "This card has no details to show" }, { status: 404 });
    }
    return NextResponse.json(revealed);
  } catch (error: unknown) {
    console.error("card_reveal_failed", whopMessage(error));
    return NextResponse.json({ error: "Could not read the card" }, { status: 502 });
  }
}

const patchBody = z.object({
  change: z.union([
    z.object({ frozen: z.boolean() }),
    z.object({ canceled: z.literal(true) }),
    z.object({ name: z.string().min(1).max(40) }),
  ]),
});

export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ cardId: string }> },
) {
  if (!checkRateLimit(clientIp(request), 5)) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  const accountId = await currentAccountId();
  if (!accountId) {
    return NextResponse.json({ error: "Not signed in" }, { status: 401 });
  }

  const { cardId } = await params;
  const parsed = patchBody.safeParse(await request.json());

  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }

  try {
    const card = cardSchema.parse(
      await whopFetch<unknown>(`/cards/${cardId}`, {
        method: "PATCH",
        body: JSON.stringify({
          account_id: accountId,
          ...parsed.data.change,
        }),
      }),
    );

    return NextResponse.json({ card });
  } catch (error: unknown) {
    console.error("card_update_failed", whopMessage(error));
    return NextResponse.json({ error: whopMessage(error) }, { status: 502 });
  }
}

Watch what a customer's card spends

Each purchase becomes a transaction that has the seller's name, the amount, and if the payment went through or didn't (and why not).

Go to lib/cards/ and then create a file called transactions.ts with the content:

transactions.ts
import { z } from "zod";
import { whopFetch } from "@/lib/whop";

// Amounts here are plain dollar decimals, unlike spent_last_month on the card object, which is cents.
const transactionSchema = z.object({
  id: z.string(),
  card_id: z.string().nullish(),
  status: z.string().nullish(),
  usd_amount: z.number().nullish(),
  currency: z.string().nullish(),
  merchant_name: z.string().nullish(),
  merchant_category: z.string().nullish(),
  declined_reason: z.string().nullish(),
  created_at: z.string().nullish(),
  posted_at: z.string().nullish(),
});

const pageSchema = z.object({
  data: z.array(transactionSchema),
  page_info: z
    .object({
      end_cursor: z.string().nullish(),
      has_next_page: z.boolean().nullish(),
    })
    .nullish(),
});

export type CardTransaction = z.infer<typeof transactionSchema>;

export async function listTransactions(options: {
  accountId: string;
  cardId?: string;
  createdAfter?: string;
  limit?: number;
}): Promise<CardTransaction[]> {
  const collected: CardTransaction[] = [];
  const limit = options.limit ?? 100;

  let cursor: string | undefined;

  // Cursor pagination, unlike GET /cards which has no pagination at all.
  do {
    const query = new URLSearchParams({ account_id: options.accountId, first: "50" });
    if (options.cardId) query.set("card_id", options.cardId);
    if (options.createdAfter) query.set("created_after", options.createdAfter);
    if (cursor) query.set("after", cursor);

    const page = pageSchema.parse(
      await whopFetch<unknown>(`/card_transactions?${query.toString()}`),
    );

    collected.push(...page.data);

    cursor = page.page_info?.has_next_page
      ? (page.page_info.end_cursor ?? undefined)
      : undefined;
  } while (cursor && collected.length < limit);

  return collected.slice(0, limit);
}

Know when a customer is approved

We don't want to watch every customer's account to find out whether their application has cleared. Once you start scaling, this will get exponentially hard, so, Whop sends us a webhook instead.

You can create the webhook by going into the Developer page of your business dashboard at Whop.com and clicking the Create button under the Webhooks section.

There, subscribe to the events:

  • card_application.approved
  • card_application.denied
  • card_transaction.created
  • card_transaction.completed
  • card_transaction.declined

Then, copy the webhook secret (starts with ws_) to your environment variables under WHOP_WEBHOOK_SECRET.

Then, go to app/api/webhooks/ and create a file called route.ts with the content:

script.ts
import { unwrapWebhook } from "@whop/sdk/helpers";
import { getEnv } from "@/lib/env";

export async function POST(request: Request): Promise<Response> {

  const payload = await request.text();

  let event: {
    type: string;
    account_id?: string | null;
    company_id?: string | null;
    data: Record<string, unknown>;
  };

  try {
    event = unwrapWebhook(payload, {
      headers: Object.fromEntries(request.headers),
      key: getEnv().WHOP_WEBHOOK_SECRET,
    });
  } catch {
    return new Response("Bad signature", { status: 401 });
  }

  const accountId = event.account_id ?? event.company_id ?? null;

  if (event.type === "card_application.approved" && accountId) {
  }

  return new Response("OK", { status: 200 });
}

There are two things you want to keep in your own records: the account ID of your customer from the day you connect their account, and mark them approved when the event lands with the webhook.

This makes issuing a card for them later take a single call.

Start issuing virtual cards with Whop

The money stays in your customer's account, the approval stays with them, and your platform's job is to ask for the card at the right moment.

This is just one of the things Whop can help you build. You can also integrate a checkout, gate content behind a paywall, or add user authentication to your app.

If you don't have an app of your own yet, guides like building a Gumroad clone can get you started from scratch. To learn more, check out our other tutorials and the Whop developer docs.