---
title: "Global payouts API guide: How to pay anyone in 200+ countries"
slug: global-payouts-api
excerpt: "You can pay sellers in over 200 countries using Next.js and the Whop infrastructure, without ever building a payout form or storing bank details. Learn how to in this guide."
customExcerpt: "You can pay sellers in over 200 countries using Next.js and the Whop infrastructure, without ever building a payout form or storing bank details. Learn how to in this guide."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/blog-Global-payouts-API-guide.png"
status: published
publishedAt: "2026-08-05T18:52:33.000Z"
updatedAt: "2026-08-05T18:52:33.000Z"
createdAt: "2026-08-05T18:52:36.219Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# Global payouts API guide: How to pay anyone in 200+ countries

## Key takeaways

- You can pay sellers in over 200 countries from a Next.js app using the Whop infrastructure, without ever building a payout form or storing bank details.
- Each seller gets their own connected account, and Whop draws every screen that touches a passport, an IBAN, or a wallet address.
- A seller who has not finished verifying returns an empty list of payout options and a 200, which reads like an unsupported country but is not.

<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">✨</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>

You can pay your sellers directly on your website without building a payout form, storing bank details, or writing county-specific code. Whop helps you pay out to over [over 200 countries](https://docs.whop.com/manage-your-business/manage-payouts/set-up-payouts), and you don't have to build the complex payout backend.

In this tutorial we're going to take a look at creating an account for a seller in another country, letting them prove who they are and choose how they want to be paid, sending them their money with one call, and following that payment until it lands.

You can walk the whole flow in our [companion demo here](https://nextjs-whop-payouts-demo.vercel.app) and read its [repository here](https://github.com/whopio/whop-tutorials/tree/main/global-payouts).

## Prerequisites

![Prerequisites of integrating a payouts API](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/prerequisites.webp)

We're going to work on an existing Next.js app and add files under `lib/`, `constants/` and `app/api/`, plus one line in `next.config.ts`. Although code is TypeScript, nothing here depends on using it.

### Set up a sandbox account

Until the end of this tutorial, we're going to use the Whop sandbox. This will allow us to simulate how the payout would work without moving real money.

Now, you should go to [sandbox.whop.com](https://sandbox.whop.com/), create an account, then create a business with the Start a business button in the sidebar and open its dashboard. We'll cover the switch to production at the end of the tutorial.

### Get an API key

Once you're on your new whop's dashboard, open the Developer page and find the Company API keys section. Create a key there using the Create button with the permissions:

- `company:create_child`
- `company:basic:read`
- `company:balance:read`
- `payout:account:read`
- `payout:destination:read`
- `payout:create_destination`
- `payout:transfer_funds`
- `payout:withdraw_funds`
- `payout:withdrawal:read`
- `webhook_receive:withdrawals`

### Install the packages

Then, let's install the packages we'll use with the command:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk @whop/embedded-components-react-js @whop/embedded-components-vanilla-js zod</code></pre>
  </div>
</div>

`@whop/sdk` allows our server to talk to Whop. The two embedded-components packages are the screens Whop adds for us, and the React one needs the vanilla one beside it. `zod` checks that things arriving from outside are shaped the way we expect.

### Environment variables

Create or open `.env.local` in the project root:

<table>
<thead>
<tr>
<th>Variable</th>
<th>Example</th>
<th>Where it comes from</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>WHOP_COMPANY_API_KEY</code></td>
<td><code>apik_...</code></td>
<td>The key we just made</td>
</tr>
<tr>
<td><code>WHOP_PLATFORM_ACCOUNT_ID</code></td>
<td><code>biz_...</code></td>
<td>The business ID, can be found in the dashboard URL</td>
</tr>
<tr>
<td><code>WHOP_WEBHOOK_SECRET</code></td>
<td><code>ws_...</code></td>
<td>Shown when we create the webhook, later</td>
</tr>
<tr>
<td><code>DEMO_SELLER_EMAIL</code></td>
<td><code>you@example.com</code></td>
<td>A mailbox you own, standing in for a seller's own address</td>
</tr>
<tr>
<td><code>WHOP_SANDBOX</code></td>
<td><code>true</code></td>
<td>Typed by hand. Remove it or set <code>false</code> for production</td>
</tr>
<tr>
<td><code>SESSION_PASSWORD</code></td>
<td>32 random characters</td>
<td>Typed by hand. Encrypts our own cookie</td>
</tr>
<tr>
<td><code>APP_URL</code></td>
<td><code>http://localhost:3000</code></td>
<td>Our app's own address</td>
</tr>
</tbody>
</table>

### Check them on startup

Create `lib/env.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

const schema = z.object({
  WHOP_COMPANY_API_KEY: z.string().min(1, &quot;WHOP_COMPANY_API_KEY is missing&quot;),
  WHOP_PLATFORM_ACCOUNT_ID: z
    .string()
    .startsWith(&quot;biz_&quot;, &quot;WHOP_PLATFORM_ACCOUNT_ID must start with biz_&quot;),
  WHOP_WEBHOOK_SECRET: z.string().optional(),
  DEMO_SELLER_EMAIL: z.string().email(&quot;DEMO_SELLER_EMAIL must be an email address&quot;),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((value) =&gt; value === &quot;true&quot;),
  SESSION_PASSWORD: z
    .string()
    .min(32, &quot;SESSION_PASSWORD must be at least 32 characters&quot;),
  APP_URL: z.string().url(&quot;APP_URL must be a full URL&quot;),
});

export type Env = z.infer&lt;typeof schema&gt;;

let cached: Env | undefined;

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

  const raw: Record&lt;string, string | undefined&gt; = { ...process.env };
  for (const key of Object.keys(raw)) {
    if (raw[key] === &quot;&quot;) delete raw[key];
  }

  const parsed = schema.safeParse(raw);

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

  cached = parsed.data;
  return cached;
}</code></pre>
  </div>
</div>

### The client

Now, let's create the client. Keep in mind that the option is `baseURL` with a capital `URL`. A lowercase `baseUrl` would be ignored and every call would go to production instead.

Also, the path has to carry `/api/v1` as well, or the everything would 404. Now, create `lib/whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Whop } from &quot;@whop/sdk&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

let cached: Whop | undefined;

export function whop(): Whop {
  if (cached) return cached;

  const env = getEnv();

  cached = new Whop({
    apiKey: env.WHOP_COMPANY_API_KEY,
    baseURL: env.WHOP_SANDBOX
      ? &quot;https://sandbox-api.whop.com/api/v1&quot;
      : &quot;https://api.whop.com/api/v1&quot;,
    ...(env.WHOP_WEBHOOK_SECRET
      ? { webhookKey: Buffer.from(env.WHOP_WEBHOOK_SECRET).toString(&quot;base64&quot;) }
      : {}),
  });

  return cached;
}</code></pre>
  </div>
</div>

## How paying a seller works

![How paying a seller works](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/how-paying-a-seller-works.webp)

To understand how paying a seller works, we need to understand four concepts: accounts, connected accounts, payout methods, and payouts.

An account is a business or a person on Whop, their IDs start with `biz_`. A connected account is what we create as a child to our business, sellers get their own without signing up and manually creating a company on Whop.

A payout method is an individual's saved method of receiving money, and payout is the money actually moving.

This is also the order of payouts: we create the account, the seller proves who they are, the seller saves a payout method, we send a payout against it.

## Turn on payouts

Payouts are no available until Whop has checked our own business, and the fastest way to see where we stand is the CLI. You can download it by running the command:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install -g @whop/cli</code></pre>
  </div>
</div>

Then, point it at sandbox and sign in with the company API key we made. That environment variable is not remembered between shells, so it goes on every command:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">export WHOP_API_BASE_URL=&quot;https://sandbox-api.whop.com/api/v1&quot;
whop auth login --method api-key --apiKey apik_your_key</code></pre>
  </div>
</div>

> 

Then read the account back using the command:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">whop accounts get --account_id biz_your_account --format json</code></pre>
  </div>
</div>

> 

In the response we get, `capabilities.standard_payout` tells us whether we have payouts enabled, and `required_actions` lists what we still need to do. Each entry comes with a `cta` link to the dashboard page that clears it.

## Create an account for the seller

A seller joins the marketplace, so we make them an account. Create `app/api/seller/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { getVisitorId } from &quot;@/lib/session&quot;;
import { readState, writeState } from &quot;@/lib/store&quot;;
import { checkRateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;
import { SELLER_COUNTRIES } from &quot;@/constants/whop-ids&quot;;

const countryCodes = SELLER_COUNTRIES.map((c) =&gt; c.code);

const body = z.object({
  title: z.string().min(1).max(60),
  country: z.enum(countryCodes as [string, ...string[]]),
});

function demoEmail(base: string, sellerRef: string): string {
  const [name, domain] = base.split(&quot;@&quot;);
  return `${name}+payouts-${sellerRef.slice(0, 8)}@${domain}`;
}

export async function GET() {
  const state = await readState();

  return NextResponse.json({
    sellerId: state.sellerId ?? null,
    title: state.sellerTitle ?? null,
    country: state.sellerCountry ?? null,
  });
}

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

  const parsed = body.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json({ error: &quot;Invalid request&quot; }, { status: 400 });
  }

  const env = getEnv();

  const sellerRef = await getVisitorId();

  const title = `${parsed.data.title} ${sellerRef.slice(0, 4)}`;

  try {
    const seller = await whop().companies.create({
      title,
      country: parsed.data.country as never,
      parent_company_id: env.WHOP_PLATFORM_ACCOUNT_ID,
      email: demoEmail(env.DEMO_SELLER_EMAIL, sellerRef),
      send_customer_emails: false,
      metadata: { seller_ref: sellerRef },
    });

    await writeState({
      sellerId: seller.id,
      sellerTitle: title,
      sellerCountry: parsed.data.country,
    });

    return NextResponse.json({
      sellerId: seller.id,
      title,
      country: parsed.data.country,
    });
  } catch (error) {
    console.error(&quot;seller_create_failed&quot;, error);
    return NextResponse.json(
      { error: whopMessage(error) ?? &quot;Could not create the seller account&quot; },
      { status: 502 },
    );
  }
}

function whopMessage(error: unknown): string | null {
  if (typeof error !== &quot;object&quot; || error === null) return null;
  const shape = error as { error?: { error?: { message?: unknown } } };
  const message = shape.error?.error?.message;
  return typeof message === &quot;string&quot; ? message : null;
}</code></pre>
  </div>
</div>

The line that makes this a connected account rather than an unrelated business is `parent_company_id`. `country` is where the seller is located, and the `metadata` is where we keep our own ID for them so we can find the record later.

The thing that decides if a seller can be paid isn't something we pass, it's the Whop verification the user does in the next step.

Keep in mind that two sellers under the same platform can't have the same name, and the email is required as soon as `parent_company_id` is set, and it has to be a real mailbox, because Whop checks that the address actually accepts mail.

Everything we'll do from now on needs the `biz_` ID, so make sure to save it. There is more on the wider flow in [Whop's connected accounts guide](https://docs.whop.com/developer/platforms/enroll-connected-accounts).

## Let the seller prove who they are

Whop needs to know who is being paid before a payout can be made. Sellers do that inside our page, but we don't need to store the KYC information ourselves.

The components need a credential, so our server mints one. Create `app/api/token/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { readState } from &quot;@/lib/store&quot;;
import { PAYOUT_SESSION_SCOPES } from &quot;@/constants/whop-ids&quot;;

export async function POST() {
  const state = await readState();

  if (!state.sellerId) {
    return NextResponse.json({ error: &quot;No seller yet&quot; }, { status: 409 });
  }

  try {
    const created = await whop().accessTokens.create({
      company_id: state.sellerId,
      scoped_actions: [...PAYOUT_SESSION_SCOPES],
    });

    return NextResponse.json({
      token: created.token,
      expiresAt: created.expires_at,
    });
  } catch (error) {
    console.error(&quot;token_create_failed&quot;, error);
    return NextResponse.json({ error: &quot;Could not create a session&quot; }, { status: 502 });
  }
}</code></pre>
  </div>
</div>

The scopes live in `constants/whop-ids.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop-ids.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export const PAYOUT_SESSION_SCOPES = [
  &quot;company:balance:read&quot;,
  &quot;payout:account:read&quot;,
  &quot;payout:account:update&quot;,
  &quot;payout:withdraw_funds&quot;,
  &quot;payout:withdrawal:read&quot;,
  &quot;payout:transfer:read&quot;,
  &quot;payout:transfer:export&quot;,
  &quot;payout:destination:read&quot;,
  &quot;payout:create_destination&quot;,
  &quot;payout:update_destination&quot;,
  &quot;payout:delete_destination&quot;,
] as const;</code></pre>
  </div>
</div>

Now the page. The components load through a provider, which is also where we say we're in sandbox. Create `components/WhopElementsProvider.tsx`:

Inside that, a session for one seller can wrap whichever element we want:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">WhopElementsProvider.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useMemo, type ReactNode } from &quot;react&quot;;
import { loadWhopElements } from &quot;@whop/embedded-components-vanilla-js&quot;;
import { Elements } from &quot;@whop/embedded-components-react-js&quot;;

export function WhopElementsProvider({
  children,
  sandbox,
}: {
  children: ReactNode;
  sandbox: boolean;
}) {
  const elements = useMemo(
    () =&gt;
      loadWhopElements({
        environment: sandbox ? &quot;sandbox&quot; : &quot;production&quot;,
        appearance: { theme: { appearance: &quot;light&quot; } },
        locale: &quot;en&quot;,
      }),
    [sandbox],
  );

  return &lt;Elements elements={elements}&gt;{children}&lt;/Elements&gt;;
}</code></pre>
  </div>
</div>

We want to pass `token` as a function, not a string, because the components then refresh it themselves before it expires.

The components are served from `apollo.elements.whop.com`, so you need to edit your `next.config.ts` file to allow it:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">next-config.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const csp = [
  &quot;default-src &#039;self&#039;&quot;,
  &quot;script-src &#039;self&#039; &#039;unsafe-inline&#039; &#039;unsafe-eval&#039; https://js.whop.com https://apollo.elements.whop.com https://t.whop.tw&quot;,
  &quot;style-src &#039;self&#039; &#039;unsafe-inline&#039;&quot;,
  &quot;img-src &#039;self&#039; data: https://*.whop.com&quot;,
  &quot;font-src &#039;self&#039;&quot;,
  &quot;frame-src https://*.whop.com https://apollo.elements.whop.com&quot;,
  &quot;connect-src &#039;self&#039; https://*.whop.com https://apollo.elements.whop.com https://t.whop.tw&quot;,
  &quot;form-action &#039;self&#039;&quot;,
].join(&quot;; &quot;);</code></pre>
  </div>
</div>

## Let the seller choose how to get paid

In the same wrapper, we'll use a different element now. `AddPayoutMethodElement` lets the seller know what their chosen bank needs (like IBAN numbers or wallet addresses).

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">AddPayoutMethodElement</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&lt;PayoutsSession companyId={sellerId} redirectUrl={redirectUrl} token={getToken}&gt;
  &lt;AddPayoutMethodElement /&gt;
&lt;/PayoutsSession&gt;</code></pre>
  </div>
</div>

We can also see what the choices look like before the seller picks, them. This is how you show someone what a payout will actually cost them. Create `app/api/rails/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const page = await whop().payouts.methods.list({
  account_id: state.sellerId,
  include_available: true,
  amount,
  currency: &quot;usd&quot;,
});

const body = (page as unknown as { body?: MethodsBody }).body;
const rails = (body?.available_destinations ?? []).map(formatRail).sort(compareRails);</code></pre>
  </div>
</div>

Each destination comes back with a live quote, and they differ a lot. For the same 100$ payment, one bank charges 0,20$ while another charges 2,70$.

Sending money doesn't have a set price and now we can show the seller that before they choose.

Reading back what the seller saved is the same call without `include_available`. Take the one with `is_default` set, and keep its `potk_` ID.

## Send the money

![Send the money decisions](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/send-the-money.webp)

Now, let's take a look at sending the money. It takes one call and looks the same no matter which country we're doing the payout to. A couple things you should keep in mind are:

- Amounts are in dollars, not cents, so `250.00` sends $250
- `company_id` is the seller getting paid, not us
- `platform_covers_fees` decides who pays the delivery fee. Leave it off and the fee comes out of the seller's money. Turn it on and we pay it instead, so they get the full amount. With fees running from $0.20 to $13.26, that choice matters.

Last, in cases of retries, users shouldn't get paid twice. If the request times out and we send it again, Whop needs to see it as the same payment rather than a new one.

To make sure this doesn't happen, we save an ID of our own before calling, and reuse it on every attempt:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">TypeScript</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const paymentRecordId = state.paymentRecordId ?? randomUUID();
await writeState({ paymentRecordId });</code></pre>
  </div>
</div>

Now let's look at the actual call:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">TypeScript</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const withdrawal = await whop().withdrawals.create({
  amount: parsed.data.amount,
  company_id: state.sellerId,
  currency: &quot;usd&quot;,
  payout_method_id: state.payoutMethodId,
  platform_covers_fees: parsed.data.platformCoversFees,
  idempotency_key: paymentRecordId,
});</code></pre>
  </div>
</div>

The response to this call comes back with `status: "requested"` and an ID starting wth `wdrl_`. You should save that ID, because we'll use it in the next sections to follow the payment.

## Follow it to arrival

Notice the withdrawal came back as `requested`, not `completed`. The money takes a day or two to reach the seller, and Whop sends us a webhook each time that status changes.

You can create the webhook in the dashboard, under Developer, pointing at your URL + `/api/webhooks/whop`, and subscribe to Withdrawal Created and Withdrawal Updated events.

The webhook handler has three jobs: checking the signature, ignoring anything that is not a withdrawal, and ignoring repeats.

That last one matters because Whop resends any delivery it does not get a reply to. Create `app/api/webhooks/whop/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export async function POST(request: Request) {
  const raw = await request.text();
  const headers = Object.fromEntries(request.headers.entries());

  let event: WithdrawalEvent;
  try {
    event = whop().webhooks.unwrap(raw, { headers }) as unknown as WithdrawalEvent;
  } catch (error) {
    console.error(&quot;webhook_signature_failed&quot;, error);
    return NextResponse.json({ error: &quot;bad signature&quot; }, { status: 401 });
  }

  if (!event.type?.startsWith(&quot;withdrawal.&quot;)) {
    return NextResponse.json({ received: true, outcome: &quot;ignored&quot; });
  }

  const deliveryId = headers[&quot;webhook-id&quot;] ?? event.id;
  if (seen.has(deliveryId)) {
    return NextResponse.json({ received: true, outcome: &quot;duplicate&quot; });
  }
  seen.add(deliveryId);

  return NextResponse.json({ received: true, outcome: &quot;applied&quot; });
}</code></pre>
  </div>
</div>

The `event.data` has the entire withdrawal, so we should save `event.data.status` against our own payment record. Most payments go `requested`, then `in_transit`, then `completed`.

Seeing anything other than these means the payment stopped, and those arrive with `error_code` and `error_message` filled in.

## Going live

At this point, we can create connected accounts for sellers anywhere in the world, show them Whop's own identity and payout method screens, pay them, and follow the payment until it lands.

To switch over to production from sandbox, you should create Company API key and webhooks in the live Whop.com website and remove (or set to `false`) the `WHOP_SANDBOX` secret from your environment variables.

Then, set the `APP_URL` to your real domain, and check `capabilities.standard_payout` on the live account and clear anything in `required_actions`.

## What's next

You can now offer payouts to over 200+ countries from your app. If you're interested in other payment helper features like [embedded checkouts](https://whop.com/blog/embedded-payments/), a [checkout API](https://whop.com/blog/integrate-checkout-api/), [user authentication](https://whop.com/blog/add-user-authentication/), [express checkout buttons](https://whop.com/blog/express-checkout/), and more, make sure to check out our other [tutorials](https://whop.com/blog/t/tutorials/) and the [Whop developer docs](https://docs.whop.com/).

If you don't already have an app of your own, we also have guides that can help you get started from scratch, like building a [Gumroad clone](https://whop.com/blog/build-gumroad-clone/) or an [AI chatbot SaaS](https://whop.com/blog/build-ai-chatbot-saas/).

**[Go to Whop developer docs](https://docs.whop.com/)**
