# Stripe Connect kit

> How I run Stripe Connect in my apps: accounts with no Stripe dashboard, onboarded inside my own app through the API. An MIT-licensed TypeScript kit taken from that code, with notes from running it in production.

Web page: https://davidewins.com/stripe-connect

How I run Stripe Connect in my apps: accounts with no Stripe dashboard, onboarded inside my own app through the API. TypeScript, MIT licence.

My apps pay two kinds of people: businesses that sell through the app, and couriers who deliver. Both are paid through Stripe Connect, and neither ever sees a Stripe page. They type their details into my app, my server sends them to Stripe through the API, and Stripe verifies them in the background.

This kit is that server code taken out of my backend and made generic: no database, no business rules, just the Stripe calls and what I learned running them. Every function takes a Stripe client and plain inputs, so it works in Express, Hono, Next.js route handlers, Fastify or a queue worker. The only dependency is the official `stripe` package (built against 20.x).

## What's in it

- `createConnectedAccount`: an account with no Stripe dashboard where your platform collects the requirements, created with an idempotency key
- `submitIndividual` and `submitCompany`: personal or company details, the business profile and the terms acceptance (date, IP address and user agent) in one update, with the representative and other people added through the Persons API
- `createBankToken` and `setPayoutBankAccount`: the payout bank attached as a token, with the last four digits read back from Stripe
- `uploadVerificationDocument` and `createIdentitySession`: the ID document, uploaded by your server or captured with Stripe Identity
- `getAccountStatus`: charges and payouts on or off, and what is still due as plain English labels, each with the step in your app that collects it
- `createPlatformPayment`, `createTransfer` and `createPaymentWithFee`: separate charges and transfers, or destination charges with an application fee
- `reverseForRefund` and `retryTransfer`: a proportional clawback when an order is refunded, and safe retries for transfers that failed
- `createWebhookHandler`: signature checks against more than one secret, typed routing per event, and protection against duplicate and overlapping deliveries
- An example server, and a React Native form that posts to it
- Vitest tests with a mocked Stripe client, and an integration test that runs the kit against a local Stripe emulator

```sh
npm install stripe
cp .env.example .env   # then fill in your test keys
```

Then copy the `src` folder from the full source at the bottom of this page into your project.

## Why I don't use hosted onboarding

Stripe's default is to send the user to a Stripe page (an account link) or give them a Stripe dashboard. I don't do either. Onboarding is part of my app's own sign-up: my screens, my copy, and the user never bounces out to a browser and back. The same details (name, date of birth, phone, address) also fill in their profile in my app, so they type them once.

In Stripe's current terms that is two settings on the account: `stripe_dashboard: none` and `requirement_collection: application`. Stripe used to call this a Custom account.

My backend still creates these accounts with `type: 'custom'`, the older way to say the same thing. The kit uses controller properties, which Stripe now recommends. The only visible difference is that `controller.fees.payer` reads `application` instead of `application_custom`.

## The trade-offs

- **You own requirement collection.** Stripe tells you what it needs as field codes, and each one needs a field on one of your screens. When Stripe starts asking for something new, your app has to learn to ask for it too.
- **You own the terms of service acceptance.** Stripe can't show its agreement to someone who never visits Stripe. Your app shows it, records when the user agreed, and your server sends the date, IP address and user agent.
- **You own the support burden.** There is no Stripe dashboard for the user, so when a document is rejected or a payout fails, they come to you. Your app has to explain the problem and let them fix it.
- **The platform carries the losses.** With `losses.payments: application`, negative balances on connected accounts are yours to cover, and Stripe's fees are billed to you.
- **Not everything can be done through the API.** Stripe's risk reviews sometimes ask questions the API can't answer. For those I keep a Stripe-hosted `account_update` link one tap away on the requirements screen, as an escape hatch.

If you'd rather show Stripe's own forms inside a web app, `createAccountSession` creates an account session for Stripe's embedded components. My backend has that code too, but my apps use their own screens.

## The steps

1. The user fills in the onboarding screens: name, date of birth, home address, tax ID, bank details, and a tick box for the Stripe terms. The app records when they ticked it.
2. The app posts everything to my server in one request.
3. The server creates the account the first time, keyed on the user's id so a double tap can't make two. Then it sends the details, the business profile and the terms acceptance with the user's IP address and user agent.
4. For a company, the server creates or updates the representative with the Persons API. If they are the only owner, the same person is marked as director, owner and executive, and the lists are confirmed as complete.
5. The server turns the IBAN into a bank token, attaches it as the payout account, then reads the account straight back from Stripe and returns what is still due.
6. The app asks for an ID document. In my seller app that is Stripe Identity's camera flow, linked to the account's representative.
7. Stripe verifies everything in the background and sends `account.updated`. When `charges_enabled` and `payouts_enabled` are both true, the account can be paid.

server:

```ts
import {
  createBankToken,
  createConnectedAccount,
  createStripeClient,
  getAccountStatus,
  idempotencyKey,
  setPayoutBankAccount,
  submitIndividual,
} from './stripe-connect'

const stripe = createStripeClient(process.env.STRIPE_SECRET_KEY!)

// Once per user. The key means a double tap can't create two accounts.
const account = await createConnectedAccount(stripe, {
  country: 'IE',
  businessType: 'individual',
  metadata: { user_id: user.id },
  idempotencyKey: idempotencyKey('account', user.id),
})

// Everything the app's form collected, in one update.
await submitIndividual(stripe, account.id, {
  firstName: body.firstName,
  lastName: body.lastName,
  dob: body.dob,                    // { day, month, year }
  address: body.address,            // home address
  idNumber: body.idNumber,
  tos: {
    acceptedAt: body.tosAcceptedAt, // when they ticked the box
    ip: req.ip,                     // read on the server
    userAgent: req.get('user-agent'),
  },
})

const token = await createBankToken(stripe, {
  accountHolderName: body.accountHolderName,
  accountHolderType: 'individual',
  accountNumber: body.iban,
  country: 'IE',
  currency: 'eur',
})
await setPayoutBankAccount(stripe, account.id, token)

// Don't wait for the webhook: tell the app what's left now.
const status = await getAccountStatus(stripe, account.id)
```

Companies go through `submitCompany`, which sends the company details and sets up the representative:

```ts
await submitCompany(stripe, account.id, {
  name: body.companyName,
  registrationNumber: body.registrationNumber,
  address: body.businessAddress,    // registered address
  tos,
  representative: {
    firstName: body.firstName,
    lastName: body.lastName,
    dob: body.dob,
    address: body.homeAddress,      // their own home address
    idNumber: body.idNumber,
  },
  soleOwner: true,                  // also director, owner (100%) and executive
})
```

The app side is a form that posts to that endpoint. `examples/PayoutDetailsForm.tsx` in the full source below is a cut-down version of the last onboarding screen in my courier app.

## Terms of service acceptance

With `requirement_collection: application`, accepting Stripe's terms happens in your app. Show the Stripe Connected Account Agreement next to a tick box and record when it was ticked. `tosParams` turns that into what Stripe wants: the date in seconds, the IP address and the user agent.

Read the IP address on the server. Asking the phone for its own IP is unreliable on mobile data, so my courier app asks my server which address the request came from.

## ID documents

Stripe wants identity documents as JPG or PNG, 10 MB or less, uploaded to the connected account (with the `Stripe-Account` header) and then attached to the account by file id. `uploadVerificationDocument` checks the file and does both steps. For a company, attach the file to the Person instead.

```ts
await uploadVerificationDocument(stripe, accountId, {
  data: photo,                      // Buffer from your upload handler
  type: 'image/jpeg',               // JPG or PNG, 10 MB or less
  side: 'front',
  purpose: 'identity_document',
})
```

My seller app uses Stripe Identity instead. `createIdentitySession` links a verification session to the account's representative with `related_person`, the app opens Stripe's camera flow, and a verified result lands on the account without another call.

## Showing what's still needed

Stripe reports what an account needs as codes like `individual.verification.document`. With hosted onboarding, Stripe works out what to ask. Here, your app has to. `summarizeAccount` gives each code a plain English label and the step in your app that collects it. It splits `past_due` from `currently_due` and `eventually_due` so nothing shows twice, and attaches Stripe's reason from `requirements.errors` when something was sent and rejected.

```ts
const status = await getAccountStatus(stripe, accountId)

status.state         // 'incomplete' | 'pending' | 'restricted' | 'active' | 'rejected'
status.currentlyDue  // [{ code: 'individual.dob.day', label: 'Date of birth', step: 'personal_details' }, ...]
status.pastDue       // the same, for overdue items
status.steps         // ['personal_details', 'bank_account']: screens to open, most urgent first
status.deadline      // Date, or null
status.needsAction   // show "Finish setting up payouts"
```

My seller app's requirements screen works the same way: each code maps to a label and the screen that fixes it, with the `account_update` link underneath for anything those screens can't handle.

## Payments and fees

My main app uses separate charges and transfers. The customer pays the platform, card only, and the money stays there. After the order is done and a holding period has passed, a scheduled job pays each seller and courier their share with a transfer grouped by the order id. The platform's fee is whatever isn't transferred.

```ts
// At checkout: the customer pays the platform.
const intent = await createPlatformPayment(stripe, {
  amount: 2500,                     // €25.00, always in cents
  currency: 'eur',
  captureMethod: 'manual',          // capture when the order is collected
  metadata: { order_id: order.id },
  idempotencyKey: idempotencyKey('order-payment', order.id),
})

// Later, from a scheduled job: pay each account its share.
await createTransfer(stripe, {
  amount: sellerShare,
  currency: 'eur',
  destinationAccountId: seller.stripeAccountId,
  transferGroup: order.id,
  idempotencyKey: idempotencyKey('transfer', order.id, seller.stripeAccountId),
})
```

Card only is on purpose. Apple Pay and Google Pay run on card rails and still work, but redirect methods settle to `processing` and never reach `requires_capture`, which breaks paying now and capturing when the order is collected.

For a checkout with exactly one seller and no holding period, `createPaymentWithFee` makes a destination charge: Stripe moves the amount minus your `application_fee_amount` to the seller in the same step. My web checkout works this way.

```ts
const intent = await createPaymentWithFee(stripe, {
  amount: 2000,
  currency: 'eur',
  destinationAccountId: seller.stripeAccountId,
  fee: { percent: 5 },              // or a fixed number of cents
  metadata: { order_id: order.id },
  idempotencyKey: idempotencyKey('payment', order.id),
})
```

To see what Stripe, the platform and the seller each end up with on an order, use the [fee calculator](https://davidewins.com/stripe-connect/fees).

## Refunds

When an order is refunded, the seller gives back the same share of what they received. If a customer paid €100, the seller's share was €85 and €50 is refunded, €42.50 comes back from the seller. `reverseForRefund` runs from `charge.refunded`, works from the cumulative refunded total, subtracts what Stripe says is already reversed, and uses an idempotency key built from the charge, the transfer and the total, so running the same event twice reverses nothing extra.

```ts
'charge.refunded': async (event) => {
  const charge = event.data.object
  const order = await db.orders.findByCharge(charge.id)
  await reverseForRefund(stripe, {
    transferId: order.sellerTransferId,
    sellerShare: order.sellerShare,
    refundedTotal: charge.amount_refunded, // cumulative
    amountPaid: order.total,
    chargeId: charge.id,
  })
},
```

## Retrying failed transfers

A transfer either works or throws when you create it, so failures go into a table and an hourly job retries them, up to three attempts each. `retryTransfer` checks the account can be paid, asks Stripe whether a transfer for that order already reached the account, and only then pays.

```ts
// Hourly, over unresolved failures, oldest first.
for (const failure of await db.transferFailures.unresolved()) {
  try {
    const result = await retryTransfer(stripe, failure.accountId, failure)
    if (result.status === 'paid' || result.status === 'already_paid') {
      await db.transferFailures.resolve(failure.id, result.transfer.id)
    }
    // 'waiting': not onboarded yet. Not an attempt, so change nothing.
  } catch (err) {
    await db.transferFailures.recordAttempt(failure.id, String(err))
  }
}
```

## Webhooks

Onboarding, verification and payments all finish in the background, so webhooks are where your database learns the truth. The handler checks the signature against each secret, claims the event, and calls a typed function per event type, so `event.data.object` is already a `Stripe.Account` or `Stripe.Charge`. For connected accounts, `event.account` says whose event it is.

server:

```ts
const handleWebhook = createWebhookHandler({
  stripe,
  // Two endpoints on one URL: connected accounts, and the platform itself.
  secrets: [process.env.STRIPE_WEBHOOK_SECRET!, process.env.STRIPE_WEBHOOK_SECRET_PLATFORM!],
  claimEvent: (event) => db.webhookEvents.claim(event.id), // 'new' | 'done' | 'in_flight'
  completeEvent: (event) => db.webhookEvents.markProcessed(event.id),
  failEvent: (event, error) => db.webhookEvents.markFailed(event.id, String(error)),
  handlers: {
    'account.updated': async (event) => {
      // event.account is the acct_ id: find your user by it.
      const status = summarizeAccount(event.data.object)
      await db.users.updateByStripeAccount(event.account!, { payoutState: status.state })
    },
  },
})

// Express: raw body, and registered before app.use(express.json())
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const result = await handleWebhook(req.body, req.header('stripe-signature'))
  res.status(result.status).json(result.body)
})
```

The events my backend handles:

- `account.updated`: new requirements, verification results, accounts turned on or off
- `capability.updated`: a capability switched off
- `identity.verification_session.verified` and `identity.verification_session.requires_input`: the ID check passed or needs another try
- `payment_intent.succeeded`, `payment_intent.payment_failed` and `payment_intent.canceled`: mark orders paid here, not when the app says the payment went through
- `charge.refunded`: reverse the seller's share
- `charge.dispute.created` and `charge.dispute.closed`: a chargeback, with a deadline
- `payout.paid` and `payout.failed`: money reached the user's bank, or the bank details are wrong
- `transfer.created` and `transfer.reversed`

## Testing

Use a `sk_test_` key the whole time you build. `assertTestMode` throws on a live key, so put it at the top of seed scripts. `TEST_ONBOARDING` has the values Stripe accepts over the API: date of birth 1901-01-01, `address_full_match` as the first address line, ID number 000000000, the test file `file_identity_document_success` in place of a real photo, and an Irish test IBAN. To forward webhooks from Stripe:

```sh
stripe listen \
  --forward-to localhost:4242/webhooks/stripe \
  --forward-connect-to localhost:4242/webhooks/stripe
```

Or leave Stripe out of it. `createStripeClient` takes a host, port and protocol, so the kit can run against a local Stripe emulator, and the kit's integration test does exactly that: it creates an account, sends the details, takes a payment, transfers, refunds and checks the reversal, with webhooks going through the real signature check.

```ts
const stripe = createStripeClient('sk_test_local', {
  host: 'localhost',
  port: 12111,
  protocol: 'http',
})
```

## Things I learned in production

### Read the account back after every submit

After the onboarding request, my server fetches the account from Stripe and saves the result straight away instead of waiting for `account.updated`. Webhooks can arrive after the user is already looking at the next screen.

### Show the bank Stripe has, not the one the user typed

My courier onboarding once saved the IBAN and nothing else, so the payment settings screen showed a blank bank account forever after. Now the server reads the last four digits and the bank name back from Stripe and shows those.

### Requirement lists overlap

`past_due` is a subset of `currently_due`, and `currently_due` items also appear in `eventually_due`. Show each item once, in its most urgent group. Date of birth arrives as three codes (day, month, year), so merge by label. Stripe adds new codes over time, so keep a readable fallback for ones you haven't mapped.

### Register the webhook route before the JSON parser

The signature is computed over the exact bytes Stripe sent. Once `express.json()` has parsed the body, verification fails every time. Mount the webhook route with `express.raw()` first.

### One URL can need two signing secrets

A webhook endpoint either listens to connected accounts or to your own account, and you can't change that after creating it. I ended up with two endpoints on the same URL, each with its own secret, so the handler tries each secret in turn.

### A failed event has to stay claimable

Stripe can deliver the same event twice at the same moment, so the claim is one atomic insert against the event id. My first version treated any existing row as a duplicate. When a handler failed, Stripe's retry got a 200 and the event was lost. Now a failed or stalled claim is picked up again, and a delivery that arrives while another is still running gets a 409, so Stripe comes back later.

### There is no transfer.failed event

A transfer either succeeds or throws an error when you create it: the balance isn't available yet, the account is restricted, or the key can't make transfers. Catch it there and record it for a retry. Don't wait for a webhook that never comes.

### Waiting is not failing

My retry job used to count a seller or courier who hadn't finished onboarding as a failed attempt. After three runs the payout was dropped, before they ever had an account. Now an account that can't be paid yet is skipped without using up an attempt.

### Put the attempt number in the retry key

Stripe compares the whole request against a reused idempotency key, metadata included. My retry used one key per failure with the attempt count in the metadata, so every attempt after the first was rejected as a key collision, and that error replaced the real one. My first live payout spent three hours reporting a key collision when the real problem was a restricted API key. Now the attempt number is in the key, and checking Stripe for an existing transfer first is what stops a double payment.

### Holding transfers buys you time on disputes

I pay sellers after a holding period instead of at the moment of payment. If a dispute arrives in that window, the transfer is frozen and nothing has to be clawed back from a seller who may already have withdrawn it. Destination charges pay out immediately, which is one reason my main app uses separate transfers.

### Make idempotency keys from your own ids

Build keys from things like the order id, never random values, so a retry sends the same key and Stripe returns the first result instead of charging twice. Key a payment on the order id alone: I once keyed one on the user, the seller and the amount, and a repeat order at the same total replayed an old PaymentIntent. Long keys are hashed rather than cut short, because two long keys can share the same first 255 characters.

### Clean up if your database write fails

If you create the Stripe account and then fail to save its id, you have an orphan account and the next attempt makes another. My backend deletes the account when the save fails. The kit uses an idempotency key based on the user id instead, so the retry gets the same account back.

### Capabilities have no "restricted" status

On `capability.updated`, a restricted capability shows up as `inactive` with a disabled reason. Check both.

## Full source

Every file in the kit, ready to copy. The tests, package.json and tsconfig live next to them in the repo. Amounts are always in the smallest currency unit.

### src/index.ts

```ts
export { createStripeClient } from './client.ts'
export type { ClientOptions } from './client.ts'
export { canReceiveTransfers, createConnectedAccount } from './accounts.ts'
export type { CreateConnectedAccountInput } from './accounts.ts'
export {
  attestPersonsProvided,
  createPerson,
  submitCompany,
  submitIndividual,
  toCountryCode,
  tosParams,
  upsertRepresentative,
} from './onboarding.ts'
export type {
  Address,
  BusinessProfile,
  CompanyDetails,
  DateOfBirth,
  IndividualDetails,
  PersonDetails,
  PersonRole,
  TosAcceptance,
} from './onboarding.ts'
export { createBankToken, setPayoutBankAccount } from './bank.ts'
export type { BankDetails, PayoutBank } from './bank.ts'
export { uploadVerificationDocument } from './documents.ts'
export type { DocumentUpload } from './documents.ts'
export { createIdentitySession } from './identity.ts'
export type { IdentitySession, IdentitySessionInput } from './identity.ts'
export { createAccountSession } from './sessions.ts'
export type { EmbeddedComponent } from './sessions.ts'
export { getAccountStatus, summarizeAccount } from './status.ts'
export type { AccountState, AccountStatus } from './status.ts'
export {
  describeDisabledReason,
  describeRequirement,
  describeRequirements,
  stepsFor,
} from './requirements.ts'
export type { Requirement, Step } from './requirements.ts'
export { applicationFee, proportionalReversal } from './fees.ts'
export type { FeeRule } from './fees.ts'
export {
  createPaymentWithFee,
  createPlatformPayment,
  createTransfer,
  reverseForRefund,
} from './payments.ts'
export type {
  PaymentWithFeeInput,
  PlatformPaymentInput,
  RefundReversalInput,
  TransferInput,
} from './payments.ts'
export { MAX_TRANSFER_ATTEMPTS, retryTransfer } from './retry.ts'
export type { FailedTransfer, RetryResult } from './retry.ts'
export {
  WebhookSignatureError,
  connectedAccountId,
  createWebhookHandler,
  verifyWebhook,
} from './webhooks.ts'
export type {
  ClaimState,
  WebhookHandlerOptions,
  WebhookHandlers,
  WebhookResult,
} from './webhooks.ts'
export { idempotencyKey, idempotencyKeyForSet } from './idempotency.ts'
export {
  TEST_CARDS,
  TEST_ONBOARDING,
  assertTestMode,
  isTestKey,
  signTestPayload,
} from './testing.ts'
```

### src/client.ts

```ts
import Stripe from 'stripe'

export type ClientOptions = {
  /** Retries on network errors and 429s. Safe because writes carry idempotency keys. */
  maxNetworkRetries?: number
  /** Request timeout in milliseconds. */
  timeout?: number
  /**
   * Point the client somewhere other than api.stripe.com, such as a local
   * emulator: { host: 'localhost', port: 12111, protocol: 'http' }.
   */
  host?: string
  port?: number
  protocol?: 'http' | 'https'
}

/**
 * Create a Stripe client. Pass the secret key in from your own config
 * rather than reading process.env here, so the kit works in any runtime.
 */
export function createStripeClient(
  secretKey: string,
  options: ClientOptions = {},
): Stripe {
  if (!secretKey) {
    throw new Error('Stripe secret key is missing')
  }
  return new Stripe(secretKey, {
    maxNetworkRetries: options.maxNetworkRetries ?? 3,
    timeout: options.timeout ?? 20_000,
    ...(options.host ? { host: options.host } : {}),
    ...(options.port ? { port: options.port } : {}),
    ...(options.protocol ? { protocol: options.protocol } : {}),
  })
}
```

### src/accounts.ts

```ts
import type Stripe from 'stripe'

export type CreateConnectedAccountInput = {
  /** Two-letter country code, e.g. 'IE', 'GB', 'US'. Cannot be changed later. */
  country: string
  email?: string
  businessType?: 'individual' | 'company'
  /**
   * Capabilities to request. Accounts with no Stripe dashboard must request
   * at least one. Separate transfers and destination charges need
   * `transfers`; `card_payments` is needed if the account is ever the
   * merchant of record (on_behalf_of).
   */
  capabilities?: Array<'card_payments' | 'transfers'>
  /** Merchant category code, if you already know it. */
  mcc?: string
  /** Your own ids. Put your user or seller id here so webhooks can find it. */
  metadata?: Record<string, string>
  /** Use idempotencyKey('account', yourUserId) so a retry can't make two accounts. */
  idempotencyKey: string
}

/**
 * Create a connected account that your platform onboards over the API.
 *
 * - stripe_dashboard 'none': the seller never sees a Stripe dashboard.
 * - requirement_collection 'application': you collect every KYC field in
 *   your own app and send it with the Accounts and Persons APIs.
 * - fees and losses on the application: your platform pays Stripe's fees
 *   and covers negative balances.
 *
 * This is what Stripe used to call a Custom account. `type: 'custom'` still
 * works and gives the same behaviour, except that controller.fees.payer
 * reads 'application_custom' instead of 'application'.
 */
export async function createConnectedAccount(
  stripe: Stripe,
  input: CreateConnectedAccountInput,
): Promise<Stripe.Account> {
  if (!input.idempotencyKey) {
    throw new Error('idempotencyKey is required when creating an account')
  }
  const capabilities = input.capabilities ?? ['card_payments', 'transfers']
  if (capabilities.length === 0) {
    throw new Error('Accounts without a Stripe dashboard must request a capability')
  }
  return stripe.accounts.create(
    {
      country: input.country.toUpperCase(),
      email: input.email,
      business_type: input.businessType,
      controller: {
        stripe_dashboard: { type: 'none' },
        fees: { payer: 'application' },
        losses: { payments: 'application' },
        requirement_collection: 'application',
      },
      capabilities: Object.fromEntries(
        capabilities.map((c) => [c, { requested: true }]),
      ),
      ...(input.mcc ? { business_profile: { mcc: input.mcc } } : {}),
      metadata: input.metadata,
    },
    { idempotencyKey: input.idempotencyKey },
  )
}

/**
 * True when the account can be paid: charges and payouts both enabled.
 * Check this before creating a transfer, and record the transfer as waiting
 * (not failed) if it is false.
 */
export async function canReceiveTransfers(
  stripe: Stripe,
  accountId: string,
): Promise<boolean> {
  const account = await stripe.accounts.retrieve(accountId)
  return account.charges_enabled === true && account.payouts_enabled === true
}
```

### src/onboarding.ts

```ts
import type Stripe from 'stripe'

export type Address = {
  line1: string
  line2?: string
  city: string
  state?: string
  postalCode: string
  /** Two-letter code. Full names like 'Ireland' are converted. */
  country: string
}

export type DateOfBirth = { day: number; month: number; year: number }

export type BusinessProfile = {
  url?: string
  productDescription?: string
  supportPhone?: string
  mcc?: string
}

/**
 * Proof that the user accepted the Stripe Services Agreement in your app.
 * Record it at the moment they tick the box, not when you submit.
 */
export type TosAcceptance = {
  /** Milliseconds, e.g. Date.now() when they accepted. Sent to Stripe in seconds. */
  acceptedAt: number
  /** The user's public IP address. Read it on your server, not on the phone. */
  ip: string
  /** Browser user agent, or something like 'YourApp/ios' from a native app. */
  userAgent?: string
}

export type IndividualDetails = {
  firstName: string
  lastName: string
  email?: string
  phone?: string
  dob: DateOfBirth
  /** Home address, not the business address. */
  address: Address
  /** National ID or tax number. Spaces are removed. */
  idNumber?: string
  businessProfile?: BusinessProfile
  tos: TosAcceptance
}

export type PersonDetails = {
  firstName: string
  lastName: string
  email?: string
  phone?: string
  dob?: DateOfBirth
  address?: Address
  idNumber?: string
}

export type CompanyDetails = {
  name: string
  taxId?: string
  registrationNumber?: string
  phone?: string
  /** Registered business address. */
  address: Address
  businessProfile?: BusinessProfile
  tos: TosAcceptance
  /** The person who acts for the company. Becomes a Person with relationship.representative. */
  representative: PersonDetails & { title?: string }
  /**
   * The representative is also the only director, owner (100%) and
   * executive. Lets one person satisfy every person requirement, then
   * attests the lists are complete. Leave false if there are others, and
   * add them with createPerson.
   */
  soleOwner?: boolean
}

/** Stripe needs ISO 3166-1 alpha-2 codes. Converts a few full names. */
export function toCountryCode(country: string): string {
  const c = country.trim()
  if (c.length === 2) return c.toUpperCase()
  const names: Record<string, string> = {
    ireland: 'IE',
    'united kingdom': 'GB',
    'united states': 'US',
  }
  return names[c.toLowerCase()] ?? c
}

export function tosParams(tos: TosAcceptance): Stripe.AccountUpdateParams.TosAcceptance {
  if (!tos.ip) throw new Error('tos.ip is required: Stripe needs the IP the user accepted from')
  return {
    date: Math.floor(tos.acceptedAt / 1000),
    ip: tos.ip,
    ...(tos.userAgent ? { user_agent: tos.userAgent } : {}),
  }
}

function addressParams(a: Address) {
  return {
    line1: a.line1,
    line2: a.line2 || undefined,
    city: a.city,
    state: a.state || undefined,
    postal_code: a.postalCode,
    country: toCountryCode(a.country),
  }
}

function profileParams(p?: BusinessProfile) {
  if (!p) return undefined
  return {
    url: p.url || undefined,
    product_description: p.productDescription || undefined,
    support_phone: p.supportPhone || undefined,
    mcc: p.mcc || undefined,
  }
}

function personParams(p: PersonDetails) {
  return {
    first_name: p.firstName,
    last_name: p.lastName,
    email: p.email,
    phone: p.phone,
    dob: p.dob,
    address: p.address ? addressParams(p.address) : undefined,
    id_number: p.idNumber?.replace(/\s/g, ''),
  }
}

/**
 * Send an individual's details, business profile and terms acceptance in
 * one update. Call it again with corrections: Stripe validates on every
 * update and throws a StripeInvalidRequestError for bad values.
 */
export async function submitIndividual(
  stripe: Stripe,
  accountId: string,
  details: IndividualDetails,
): Promise<Stripe.Account> {
  return stripe.accounts.update(accountId, {
    business_type: 'individual',
    individual: personParams(details),
    business_profile: profileParams(details.businessProfile),
    tos_acceptance: tosParams(details.tos),
  })
}

/**
 * Send a company's details, then create or update its representative with
 * the Persons API. With soleOwner, the representative is also the director,
 * owner and executive, and the person lists are attested complete.
 */
export async function submitCompany(
  stripe: Stripe,
  accountId: string,
  details: CompanyDetails,
): Promise<{ account: Stripe.Account; representative: Stripe.Person }> {
  await stripe.accounts.update(accountId, {
    business_type: 'company',
    company: {
      name: details.name,
      tax_id: details.taxId,
      registration_number: details.registrationNumber,
      phone: details.phone,
      address: addressParams(details.address),
    },
    business_profile: profileParams(details.businessProfile),
    tos_acceptance: tosParams(details.tos),
  })

  const soleOwner = details.soleOwner === true
  const representative = await upsertRepresentative(stripe, accountId, details.representative, soleOwner)
  const account = soleOwner
    ? await attestPersonsProvided(stripe, accountId)
    : await stripe.accounts.retrieve(accountId)
  return { account, representative }
}

/**
 * Update the existing representative if there is one, otherwise create it.
 * Listing first means a resubmitted form never adds a second representative.
 */
export async function upsertRepresentative(
  stripe: Stripe,
  accountId: string,
  rep: PersonDetails & { title?: string },
  soleOwner: boolean,
): Promise<Stripe.Person> {
  const relationship: Stripe.AccountCreatePersonParams.Relationship = {
    representative: true,
    title: rep.title ?? (soleOwner ? 'Owner and director' : 'Representative'),
    ...(soleOwner
      ? { director: true, owner: true, executive: true, percent_ownership: 100 }
      : {}),
  }
  const params = { ...personParams(rep), relationship }

  const existing = await stripe.accounts.listPersons(accountId, {
    relationship: { representative: true },
  })
  if (existing.data.length > 0) {
    return stripe.accounts.updatePerson(accountId, existing.data[0].id, params)
  }
  return stripe.accounts.createPerson(accountId, params)
}

export type PersonRole = 'director' | 'owner' | 'executive'

/** Add another director, owner or executive to a company account. */
export async function createPerson(
  stripe: Stripe,
  accountId: string,
  person: PersonDetails & { roles: Array<PersonRole>; percentOwnership?: number },
): Promise<Stripe.Person> {
  const relationship: Stripe.AccountCreatePersonParams.Relationship = {}
  for (const role of person.roles) relationship[role] = true
  if (person.percentOwnership != null) {
    relationship.percent_ownership = person.percentOwnership
  }
  return stripe.accounts.createPerson(accountId, {
    ...personParams(person),
    relationship,
  })
}

/**
 * Tell Stripe every director, owner and executive has been added. Only call
 * it once they have: it is a statement the user is making.
 */
export async function attestPersonsProvided(
  stripe: Stripe,
  accountId: string,
): Promise<Stripe.Account> {
  return stripe.accounts.update(accountId, {
    company: {
      directors_provided: true,
      owners_provided: true,
      executives_provided: true,
    },
  })
}
```

### src/bank.ts

```ts
import type Stripe from 'stripe'
import { toCountryCode } from './onboarding.ts'

export type BankDetails = {
  accountHolderName: string
  accountHolderType: 'individual' | 'company'
  /** IBAN in Europe, or the account number elsewhere. Spaces are removed. */
  accountNumber: string
  /** Needed outside IBAN countries, e.g. a US routing number or UK sort code. */
  routingNumber?: string
  country: string
  currency: string
}

/**
 * Turn bank details into a one-time token (btok_...) on the server.
 *
 * If you would rather the account number never reaches your server, create
 * the token in the app with Stripe's client SDK instead and send only its id
 * to setPayoutBankAccount.
 */
export async function createBankToken(
  stripe: Stripe,
  bank: BankDetails,
): Promise<string> {
  const token = await stripe.tokens.create({
    bank_account: {
      country: toCountryCode(bank.country),
      currency: bank.currency.toLowerCase(),
      account_holder_name: bank.accountHolderName,
      account_holder_type: bank.accountHolderType,
      account_number: bank.accountNumber.replace(/\s/g, ''),
      ...(bank.routingNumber ? { routing_number: bank.routingNumber } : {}),
    },
  })
  return token.id
}

export type PayoutBank = {
  account: Stripe.Account
  /** What Stripe holds, for showing "Bank ending 5678". Never store the full number. */
  last4: string | null
  bankName: string | null
}

/**
 * Attach a bank token as the account's payout bank. Setting external_account
 * replaces the default bank for that currency, so this is also how a user
 * changes bank. Returns last4 and bank name read back from Stripe, so your
 * app shows what Stripe has rather than what the user typed.
 */
export async function setPayoutBankAccount(
  stripe: Stripe,
  accountId: string,
  bankToken: string,
): Promise<PayoutBank> {
  const account = await stripe.accounts.update(accountId, {
    external_account: bankToken,
  })
  const banks = await stripe.accounts.listExternalAccounts(accountId, {
    object: 'bank_account',
    limit: 1,
  })
  const bank = banks.data[0] as Stripe.BankAccount | undefined
  return {
    account,
    last4: bank?.last4 ?? null,
    bankName: bank?.bank_name ?? null,
  }
}
```

### src/documents.ts

```ts
import type Stripe from 'stripe'

// Stripe's limits for verification uploads: 10 MB, and identity documents
// must be JPG or PNG. https://docs.stripe.com/connect/handling-api-verification
const MAX_BYTES = 10 * 1024 * 1024
const IDENTITY_TYPES = ['image/jpeg', 'image/png']

export type DocumentUpload = {
  data: Buffer | Uint8Array
  /** e.g. 'image/jpeg'. */
  type: string
  name?: string
  side: 'front' | 'back'
  /**
   * 'identity_document' for a passport, driving licence or ID card.
   * 'additional_verification' for proof of address when Stripe asks for it.
   */
  purpose: 'identity_document' | 'additional_verification'
}

/**
 * Upload an ID document and attach it to an individual account in two steps:
 * files.create on the connected account, then accounts.update with the file
 * id. Returns the file. Stripe checks it in the background and reports the
 * result on account.updated.
 *
 * For a company, attach the file to the Person instead:
 * accounts.updatePerson(accountId, personId, { verification: { document: { front: file.id } } }).
 */
export async function uploadVerificationDocument(
  stripe: Stripe,
  accountId: string,
  doc: DocumentUpload,
): Promise<Stripe.File> {
  if (doc.data.byteLength > MAX_BYTES) {
    throw new Error('Document is larger than 10 MB')
  }
  if (doc.purpose === 'identity_document' && !IDENTITY_TYPES.includes(doc.type)) {
    throw new Error('Identity documents must be JPG or PNG')
  }

  const file = await stripe.files.create(
    {
      file: {
        data: doc.data as Buffer,
        name: doc.name ?? `document_${doc.side}`,
        type: doc.type,
      },
      purpose: doc.purpose,
    },
    // The file belongs to the connected account, not the platform.
    { stripeAccount: accountId },
  )

  const field = doc.purpose === 'additional_verification' ? 'additional_document' : 'document'
  await stripe.accounts.update(accountId, {
    individual: { verification: { [field]: { [doc.side]: file.id } } },
  })
  return file
}
```

### src/identity.ts

```ts
import type Stripe from 'stripe'

export type IdentitySessionInput = {
  accountId: string
  email?: string
  /**
   * The API version your Stripe Identity mobile SDK expects for ephemeral
   * keys. Check the SDK's docs; it is not your server's API version.
   */
  ephemeralKeyApiVersion: string
  metadata?: Record<string, string>
}

export type IdentitySession = {
  sessionId: string
  ephemeralKeySecret: string
  personId: string
}

/**
 * The other way to collect the ID document: Stripe Identity's camera flow
 * in the app (document plus selfie) instead of uploading photos yourself.
 * The session is linked to the account's representative with related_person,
 * so a verified result is attached to the account without another call.
 *
 * Finds the representative first and only creates one if there is none.
 */
export async function createIdentitySession(
  stripe: Stripe,
  input: IdentitySessionInput,
): Promise<IdentitySession> {
  const persons = await stripe.accounts.listPersons(input.accountId, { limit: 10 })
  let person = persons.data.find((p) => p.relationship?.representative)
  if (!person) {
    person = await stripe.accounts.createPerson(input.accountId, {
      email: input.email,
      relationship: { representative: true, owner: true, title: 'Owner' },
    })
  }

  const session = await stripe.identity.verificationSessions.create({
    type: 'document',
    provided_details: input.email ? { email: input.email } : undefined,
    related_person: { account: input.accountId, person: person.id },
    metadata: input.metadata,
  })

  const key = await stripe.ephemeralKeys.create(
    { verification_session: session.id },
    { apiVersion: input.ephemeralKeyApiVersion },
  )
  if (!key.secret) throw new Error('Stripe returned an ephemeral key without a secret')

  return { sessionId: session.id, ephemeralKeySecret: key.secret, personId: person.id }
}
```

### src/sessions.ts

```ts
import type Stripe from 'stripe'

export type EmbeddedComponent =
  | 'account_onboarding'
  | 'account_management'
  | 'notification_banner'
  | 'payments'
  | 'payment_details'
  | 'payouts'
  | 'balances'

/**
 * Account session for Stripe's embedded Connect components, if you want
 * Stripe's own forms inside your web app as well as, or instead of, your
 * own screens. Returns the client secret for Connect.js. Sessions are short
 * lived: create one per page load.
 */
export async function createAccountSession(
  stripe: Stripe,
  accountId: string,
  components: Array<EmbeddedComponent> = ['account_onboarding'],
): Promise<string> {
  const session = await stripe.accountSessions.create({
    account: accountId,
    components: Object.fromEntries(
      components.map((c) => [c, { enabled: true }]),
    ) as Stripe.AccountSessionCreateParams.Components,
  })
  return session.client_secret
}
```

### src/status.ts

```ts
import type Stripe from 'stripe'
import { describeDisabledReason, describeRequirements, stepsFor } from './requirements.ts'
import type { Requirement, Step } from './requirements.ts'

/**
 * - 'incomplete': required details have not all been sent yet.
 * - 'pending':    everything is in, Stripe is still verifying it.
 * - 'restricted': something is missing, overdue or was rejected; charges or payouts are off.
 * - 'active':     charges and payouts are on and nothing is due right now.
 * - 'rejected':   Stripe closed the account. Do not send them back to onboarding.
 */
export type AccountState =
  | 'incomplete'
  | 'pending'
  | 'restricted'
  | 'active'
  | 'rejected'

export type AccountStatus = {
  accountId: string
  state: AccountState
  chargesEnabled: boolean
  payoutsEnabled: boolean
  detailsSubmitted: boolean
  /** Plain English reason the account is disabled, if it is. */
  disabledReason: string | null
  /** When currently due items must be in by, or payouts pause. */
  deadline: Date | null
  /** Overdue. The account is or will soon be restricted. */
  pastDue: Array<Requirement>
  /** Needed now, not yet overdue. Excludes anything in pastDue. */
  currentlyDue: Array<Requirement>
  /** Needed later (e.g. at a volume threshold). Excludes the two lists above. */
  eventuallyDue: Array<Requirement>
  /** Sent and being checked. Nothing for the user to do. */
  pendingVerification: Array<Requirement>
  /** Screens to open, most urgent first, covering pastDue and currentlyDue. */
  steps: Array<Step>
  /** True when there is something the user can do right now. */
  needsAction: boolean
}

/** Turn a Stripe account object into a status your app can act on. */
export function summarizeAccount(account: Stripe.Account): AccountStatus {
  const req = account.requirements
  const errors = req?.errors ?? []
  const pastDueCodes = req?.past_due ?? []
  // past_due is a subset of currently_due, and currently_due items also sit
  // in eventually_due. Split them so nothing is listed twice.
  const currentCodes = (req?.currently_due ?? []).filter(
    (c) => !pastDueCodes.includes(c),
  )
  const eventualCodes = (req?.eventually_due ?? []).filter(
    (c) => !pastDueCodes.includes(c) && !currentCodes.includes(c),
  )
  const pendingCodes = req?.pending_verification ?? []

  const chargesEnabled = account.charges_enabled === true
  const payoutsEnabled = account.payouts_enabled === true
  const detailsSubmitted = account.details_submitted === true
  const rawReason = req?.disabled_reason ?? null
  const dueNow = pastDueCodes.length + currentCodes.length

  let state: AccountState
  if (rawReason?.startsWith('rejected')) {
    state = 'rejected'
  } else if (chargesEnabled && payoutsEnabled && dueNow === 0) {
    state = 'active'
  } else if (dueNow === 0 && pendingCodes.length > 0) {
    state = 'pending'
  } else if (!detailsSubmitted) {
    state = 'incomplete'
  } else {
    state = 'restricted'
  }

  const pastDue = describeRequirements(pastDueCodes, errors)
  const currentlyDue = describeRequirements(currentCodes, errors)

  return {
    accountId: account.id,
    state,
    chargesEnabled,
    payoutsEnabled,
    detailsSubmitted,
    disabledReason: describeDisabledReason(rawReason),
    deadline: req?.current_deadline ? new Date(req.current_deadline * 1000) : null,
    pastDue,
    currentlyDue,
    eventuallyDue: describeRequirements(eventualCodes, errors),
    pendingVerification: describeRequirements(pendingCodes),
    steps: stepsFor([...pastDue, ...currentlyDue]),
    needsAction: state !== 'rejected' && pastDue.length + currentlyDue.length > 0,
  }
}

/**
 * Fetch an account fresh from Stripe and summarise it. Call it right after
 * every onboarding submit and whenever the user opens a payments screen:
 * webhooks can arrive after the user is already looking.
 */
export async function getAccountStatus(
  stripe: Stripe,
  accountId: string,
): Promise<AccountStatus> {
  const account = await stripe.accounts.retrieve(accountId)
  return summarizeAccount(account)
}
```

### src/requirements.ts

```ts
import type Stripe from 'stripe'

/**
 * Stripe reports what an account still needs as field codes like
 * 'individual.dob.day' or 'external_account'. When your platform collects
 * requirements, your app has to turn each code into a field on one of your
 * own screens. These helpers give each code a label to show and the step
 * (screen) that collects it.
 */

/** Which of your onboarding screens collects a requirement. */
export type Step =
  | 'business_type'
  | 'personal_details'
  | 'home_address'
  | 'id_number'
  | 'identity_document'
  | 'proof_of_address'
  | 'business_profile'
  | 'company_details'
  | 'company_people'
  | 'bank_account'
  | 'terms'
  /**
   * Risk and compliance requests (codes starting interv_) that the API
   * can't answer. Send the user to Stripe through an account_update link or
   * an embedded component, or handle it from your platform dashboard.
   */
  | 'stripe_review'
  | 'other'

export type Requirement = {
  /** The raw Stripe code, e.g. 'individual.verification.document'. */
  code: string
  label: string
  step: Step
  description?: string
  /** Why Stripe rejected what was sent, from requirements.errors. Ask again. */
  error?: string
}

type Entry = { label: string; step: Step; description?: string }

// Keys are matched against the end of the code, so 'dob.day' covers
// individual.dob.day, representative.dob.day and person_xxx.dob.day.
const LABELS: Record<string, Entry> = {
  'business_type': { label: 'Business type', step: 'business_type' },
  'first_name': { label: 'First name', step: 'personal_details' },
  'last_name': { label: 'Last name', step: 'personal_details' },
  'dob.day': { label: 'Date of birth', step: 'personal_details' },
  'dob.month': { label: 'Date of birth', step: 'personal_details' },
  'dob.year': { label: 'Date of birth', step: 'personal_details' },
  'email': { label: 'Email address', step: 'personal_details' },
  'phone': { label: 'Phone number', step: 'personal_details' },
  'id_number': {
    label: 'Personal tax ID number',
    step: 'id_number',
    description: 'The national ID or tax number for this person',
  },
  'ssn_last_4': { label: 'Last 4 digits of SSN', step: 'id_number' },
  'address.line1': { label: 'Home address', step: 'home_address' },
  'address.city': { label: 'Home address', step: 'home_address' },
  'address.postal_code': { label: 'Home address', step: 'home_address' },
  'address.state': { label: 'Home address', step: 'home_address' },
  'verification.document': {
    label: 'Identity document',
    step: 'identity_document',
    description: 'A photo of a passport, driving licence or national ID card',
  },
  'verification.additional_document': {
    label: 'Proof of address',
    step: 'proof_of_address',
    description: 'A second document, such as a utility bill or bank statement',
  },
  'relationship.title': { label: 'Job title', step: 'company_people' },
  'relationship.owner': { label: 'Ownership details', step: 'company_people' },
  'relationship.percent_ownership': { label: 'Ownership percentage', step: 'company_people' },
  'external_account': {
    label: 'Bank account for payouts',
    step: 'bank_account',
    description: 'Where your earnings are paid out',
  },
  'tos_acceptance.date': { label: 'Accept the Stripe terms', step: 'terms' },
  'tos_acceptance.ip': { label: 'Accept the Stripe terms', step: 'terms' },
  'business_profile.url': {
    label: 'Business website',
    step: 'business_profile',
    description: 'A website or social profile that shows what you sell',
  },
  'business_profile.mcc': { label: 'Business category', step: 'business_profile' },
  'business_profile.product_description': {
    label: 'Description of what you sell',
    step: 'business_profile',
  },
  'business_profile.support_phone': { label: 'Customer support phone', step: 'business_profile' },
  'company.name': { label: 'Company name', step: 'company_details' },
  'company.tax_id': { label: 'Company tax ID', step: 'company_details' },
  'company.registration_number': { label: 'Company registration number', step: 'company_details' },
  'company.phone': { label: 'Company phone number', step: 'company_details' },
  'company.directors_provided': {
    label: 'Company directors',
    step: 'company_people',
    description: 'Add every director, then confirm the list is complete',
  },
  'company.owners_provided': {
    label: 'Company owners',
    step: 'company_people',
    description: 'Add everyone who owns 25% or more, then confirm',
  },
  'company.executives_provided': { label: 'Company executives', step: 'company_people' },
  'company.verification.document': { label: 'Company registration document', step: 'company_details' },
}

// Longest keys first so the most specific match wins.
const KEYS = Object.keys(LABELS).sort((a, b) => b.length - a.length)

const PERSON_PREFIX: Array<[RegExp, string]> = [
  [/^representative\./, 'Representative'],
  [/^person_[A-Za-z0-9]+\./, 'Company person'],
  [/^directors\./, 'Director'],
  [/^owners\./, 'Owner'],
  [/^executives\./, 'Executive'],
]

export function describeRequirement(code: string): Requirement {
  if (code.startsWith('interv_')) {
    return {
      code,
      label: 'Stripe needs more information',
      step: 'stripe_review',
      description: 'Stripe has asked a question this app cannot answer for you',
    }
  }
  let rest = code
  let who: string | undefined
  for (const [pattern, name] of PERSON_PREFIX) {
    if (pattern.test(code)) {
      rest = code.replace(pattern, '')
      who = name
      break
    }
  }
  if (rest.startsWith('company.address.')) {
    return { code, label: 'Registered business address', step: 'company_details' }
  }
  const key = KEYS.find((k) => rest === k || rest.endsWith(`.${k}`))
  if (key) {
    const entry = LABELS[key]
    return {
      code,
      label: who ? `${who}: ${lowerFirst(entry.label)}` : entry.label,
      step: who && entry.step === 'personal_details' ? 'company_people' : entry.step,
      description: entry.description,
    }
  }
  return { code, label: humanise(code), step: 'other' }
}

/**
 * Describe a list of codes, merging ones that read the same to a person
 * (dob.day, dob.month and dob.year are one "Date of birth" item), and
 * attaching Stripe's reason when a field was sent and rejected.
 */
export function describeRequirements(
  codes: ReadonlyArray<string>,
  errors: ReadonlyArray<Stripe.Account.Requirements.Error> = [],
): Array<Requirement> {
  const seen = new Map<string, Requirement>()
  for (const code of codes) {
    const item = describeRequirement(code)
    const error = errors.find((e) => e.requirement === code)?.reason
    const existing = seen.get(item.label)
    if (existing) {
      if (error && !existing.error) existing.error = error
      continue
    }
    seen.set(item.label, error ? { ...item, error } : item)
  }
  return [...seen.values()]
}

/** The distinct screens the user has to visit, in the order they appear. */
export function stepsFor(requirements: ReadonlyArray<Requirement>): Array<Step> {
  return [...new Set(requirements.map((r) => r.step))]
}

const DISABLED_REASONS: Record<string, string> = {
  'requirements.past_due':
    'Some required details are overdue. Add them to turn payments back on.',
  'requirements.pending_verification':
    'Stripe is checking the details you sent. This usually takes minutes, sometimes a few days.',
  'listed': 'The account is on a restricted list and is under review.',
  'platform_paused': 'Payments are paused by the platform.',
  'rejected.fraud': 'Stripe closed this account for suspected fraud.',
  'rejected.incomplete_verification': 'Stripe closed this account because verification was not finished in time.',
  'rejected.terms_of_service': 'Stripe closed this account for a terms breach.',
  'rejected.listed': 'Stripe closed this account because it is on a restricted list.',
  'rejected.other': 'Stripe closed this account.',
  'under_review': 'Stripe is reviewing this account.',
  'other': 'The account is disabled. Contact support.',
  'action_required.requested_capabilities':
    'Some details are needed before payments can be turned on.',
}

export function describeDisabledReason(
  reason: string | null | undefined,
): string | null {
  if (!reason) return null
  return DISABLED_REASONS[reason] ?? `The account is restricted (${reason}).`
}

function lowerFirst(s: string) {
  return s.charAt(0).toLowerCase() + s.slice(1)
}

function humanise(code: string) {
  const text = code.replace(/[._]/g, ' ').trim()
  return text.charAt(0).toUpperCase() + text.slice(1)
}
```

### src/fees.ts

```ts
export type FeeRule = {
  /** Percentage of the amount, e.g. 5 for 5%. */
  percent?: number
  /** Flat amount in the smallest currency unit (cents), added on top. */
  fixed?: number
}

/**
 * Work out the platform's application fee in the smallest currency unit.
 * Rounds to the nearest cent and never returns more than the amount itself.
 *
 * applicationFee(2000, { percent: 5 })            // 100
 * applicationFee(2000, { percent: 2.9, fixed: 30 }) // 88
 */
export function applicationFee(amount: number, rule: FeeRule): number {
  assertMinorUnits(amount, 'amount')
  const percent = rule.percent ?? 0
  const fixed = rule.fixed ?? 0
  if (percent < 0 || percent > 100) {
    throw new Error(`percent must be between 0 and 100, got ${percent}`)
  }
  if (!Number.isInteger(fixed) || fixed < 0) {
    throw new Error(`fixed must be a whole number of cents, got ${fixed}`)
  }
  const fee = Math.round((amount * percent) / 100) + fixed
  return Math.min(fee, amount)
}

/**
 * How much of a seller's transfer a refund should claw back: the seller's
 * share times the fraction of the payment refunded, never more than the share.
 *
 * refundedTotal is cumulative (charge.amount_refunded), so after several
 * partial refunds this returns the total that should be reversed by now.
 * Subtract what is already reversed (transfer.amount_reversed) before
 * reversing more. reverseForRefund in payments.ts does that.
 *
 * Customer paid 10000, seller's share 8500, 5000 refunded: 4250.
 */
export function proportionalReversal(
  sellerShare: number,
  refundedTotal: number,
  amountPaid: number,
): number {
  assertMinorUnits(sellerShare, 'sellerShare')
  assertMinorUnits(refundedTotal, 'refundedTotal')
  assertMinorUnits(amountPaid, 'amountPaid')
  if (sellerShare === 0 || refundedTotal === 0 || amountPaid === 0) return 0
  const fraction = Math.min(refundedTotal, amountPaid) / amountPaid
  return Math.min(sellerShare, Math.round(sellerShare * fraction))
}

export function assertMinorUnits(value: number, name: string) {
  if (!Number.isInteger(value) || value < 0) {
    throw new Error(
      `${name} must be a whole number in the smallest currency unit (e.g. cents), got ${value}`,
    )
  }
}
```

### src/payments.ts

```ts
import type Stripe from 'stripe'
import { applicationFee, assertMinorUnits, proportionalReversal } from './fees.ts'
import type { FeeRule } from './fees.ts'

// Most currencies have a minimum charge around 50 cents. Check Stripe's
// list for yours; this catches the common zero or tiny amount bug early.
const DEFAULT_MINIMUM = 50

function assertChargeable(amount: number, minimum = DEFAULT_MINIMUM) {
  assertMinorUnits(amount, 'amount')
  if (amount < minimum) {
    throw new Error(`amount ${amount} is below the minimum of ${minimum}`)
  }
}

export type PlatformPaymentInput = {
  /** Total the customer pays, in the smallest currency unit (cents). */
  amount: number
  currency: string
  customerId?: string
  /** 'manual' authorises now and captures later, e.g. when the order is collected. */
  captureMethod?: 'automatic' | 'manual'
  description?: string
  /** Put your order id here so the webhook can match the payment back. */
  metadata?: Record<string, string>
  /** Required. Key it on the order id only: idempotencyKey('order-payment', orderId). */
  idempotencyKey: string
}

/**
 * Separate charges and transfers, step one: the customer pays the platform
 * and the money stays there. Pay the seller (and anyone else on the order)
 * later with createTransfer, using the order id as transfer_group. Your fee
 * is whatever you don't transfer.
 *
 * Card only: wallets like Apple Pay and Google Pay run on card rails and are
 * included. Redirect methods settle to 'processing' and never reach
 * 'requires_capture', which breaks authorise-now, capture-later.
 */
export async function createPlatformPayment(
  stripe: Stripe,
  input: PlatformPaymentInput,
): Promise<Stripe.PaymentIntent> {
  assertChargeable(input.amount)
  if (!input.idempotencyKey) throw new Error('idempotencyKey is required for payments')
  return stripe.paymentIntents.create(
    {
      amount: input.amount,
      currency: input.currency.toLowerCase(),
      customer: input.customerId,
      capture_method: input.captureMethod ?? 'automatic',
      payment_method_types: ['card'],
      description: input.description,
      metadata: input.metadata,
    },
    { idempotencyKey: input.idempotencyKey },
  )
}

export type PaymentWithFeeInput = {
  amount: number
  currency: string
  /** The seller's connected account (acct_...). */
  destinationAccountId: string
  /** Either a fixed fee in cents or a rule to calculate one. */
  fee: number | FeeRule
  /**
   * Make the seller the merchant of record: their name on the card statement,
   * their country's pricing. Needs the card_payments capability on the account.
   */
  onBehalfOf?: boolean
  customerId?: string
  description?: string
  metadata?: Record<string, string>
  /** Required. Use idempotencyKey('payment', orderId). */
  idempotencyKey: string
}

/**
 * Destination charge: the customer pays the platform, and in the same step
 * Stripe moves the amount minus your application fee to one seller. Simpler
 * than separate transfers when an order has exactly one seller and you don't
 * need to hold the money. Returns the PaymentIntent; send its client_secret
 * to your app to confirm the payment.
 */
export async function createPaymentWithFee(
  stripe: Stripe,
  input: PaymentWithFeeInput,
): Promise<Stripe.PaymentIntent> {
  assertChargeable(input.amount)
  const fee =
    typeof input.fee === 'number' ? input.fee : applicationFee(input.amount, input.fee)
  assertMinorUnits(fee, 'fee')
  if (fee > input.amount) {
    throw new Error(`fee ${fee} is more than the amount ${input.amount}`)
  }
  if (!input.idempotencyKey) throw new Error('idempotencyKey is required for payments')

  return stripe.paymentIntents.create(
    {
      amount: input.amount,
      currency: input.currency.toLowerCase(),
      automatic_payment_methods: { enabled: true },
      application_fee_amount: fee,
      transfer_data: { destination: input.destinationAccountId },
      ...(input.onBehalfOf ? { on_behalf_of: input.destinationAccountId } : {}),
      customer: input.customerId,
      description: input.description,
      metadata: input.metadata,
    },
    { idempotencyKey: input.idempotencyKey },
  )
}

export type TransferInput = {
  amount: number
  currency: string
  destinationAccountId: string
  /** Your order id. Groups every transfer for the order, and lets a retry check what was already paid. */
  transferGroup: string
  /** Ties the transfer to a charge so it can use funds not yet available. */
  sourceTransaction?: string
  description?: string
  metadata?: Record<string, string>
  /** Use idempotencyKey('transfer', orderId, accountId). */
  idempotencyKey: string
}

/**
 * Separate charges and transfers, step two: pay a connected account its
 * share of an order.
 *
 * There is no "transfer failed" webhook: a transfer either fails right here
 * with an error (balance not available yet, account restricted, a key that
 * can't make transfers) or succeeds. Catch the error, record it, and retry
 * it with retryTransfer.
 */
export async function createTransfer(
  stripe: Stripe,
  input: TransferInput,
): Promise<Stripe.Transfer> {
  assertMinorUnits(input.amount, 'amount')
  if (input.amount === 0) throw new Error('amount must be more than 0')
  return stripe.transfers.create(
    {
      amount: input.amount,
      currency: input.currency.toLowerCase(),
      destination: input.destinationAccountId,
      transfer_group: input.transferGroup,
      source_transaction: input.sourceTransaction,
      description: input.description,
      metadata: input.metadata,
    },
    { idempotencyKey: input.idempotencyKey },
  )
}

export type RefundReversalInput = {
  transferId: string
  /** What the seller was meant to get from this transfer. */
  sellerShare: number
  /** charge.amount_refunded: the cumulative total refunded so far. */
  refundedTotal: number
  /** What the customer paid for the order. */
  amountPaid: number
  /** The refunded charge, for the idempotency key and metadata. */
  chargeId: string
  metadata?: Record<string, string>
}

/**
 * Claw back the seller's proportional share of a refund from their transfer.
 * Run it from charge.refunded. Sized against what Stripe says is already
 * reversed, and keyed on the charge, transfer and refunded total, so it is
 * safe to run again for the same event. Returns null when nothing is left
 * to reverse.
 *
 * Throws if Stripe refuses, e.g. the seller's balance is too low. Record
 * that for a person to resolve; retrying the webhook will not change it.
 */
export async function reverseForRefund(
  stripe: Stripe,
  input: RefundReversalInput,
): Promise<Stripe.TransferReversal | null> {
  const target = proportionalReversal(input.sellerShare, input.refundedTotal, input.amountPaid)
  if (target <= 0) return null

  const transfer = await stripe.transfers.retrieve(input.transferId)
  const alreadyReversed = transfer.amount_reversed || 0
  const amount = Math.min(target - alreadyReversed, transfer.amount - alreadyReversed)
  if (amount <= 0) return null

  return stripe.transfers.createReversal(
    input.transferId,
    {
      amount,
      description: 'Proportional refund',
      metadata: {
        charge_id: input.chargeId,
        refunded_total: String(input.refundedTotal),
        seller_share: String(input.sellerShare),
        ...input.metadata,
      },
    },
    {
      idempotencyKey: `refund-reversal:${input.chargeId}:${input.transferId}:${input.refundedTotal}`,
    },
  )
}
```

### src/retry.ts

```ts
import type Stripe from 'stripe'
import { assertMinorUnits } from './fees.ts'

/** A transfer that threw when you created it, as you recorded it. */
export type FailedTransfer = {
  /** Your id for the failure record. Part of the idempotency key. */
  id: string
  amount: number
  currency: string
  /** The order id you used as transfer_group. */
  transferGroup: string
  /** Failed attempts so far. Waiting runs don't count. */
  attempts: number
  metadata?: Record<string, string>
}

export type RetryResult =
  | { status: 'paid'; transfer: Stripe.Transfer }
  /** Stripe already has a transfer for this group and account. Record it; don't pay again. */
  | { status: 'already_paid'; transfer: Stripe.Transfer }
  /** The account can't be paid yet. Not an attempt: leave the count alone. */
  | { status: 'waiting' }
  /** Out of attempts. Needs a person. */
  | { status: 'gave_up' }

export const MAX_TRANSFER_ATTEMPTS = 3

/**
 * Retry one failed transfer. Run it from a scheduled job over your failure
 * records, oldest first, in small batches.
 *
 * 1. If the account doesn't have charges and payouts on, the user hasn't
 *    finished onboarding. That is waiting, not failing: counting it as an
 *    attempt would drop the payout before they ever get set up.
 * 2. Ask Stripe for transfers in the same transfer_group to that account. If
 *    an earlier attempt reached Stripe but your bookkeeping failed after it,
 *    this finds it instead of paying twice. This assumes one transfer per
 *    account per order, which is how the transfers in this kit are keyed.
 * 3. Create the transfer with the attempt number in the idempotency key.
 *    Stripe compares the whole request against a reused key, so a fixed key
 *    with changing metadata is rejected as a key collision, which hides the
 *    real error. Step 2 is what makes a changing key safe.
 *
 * What to record: on 'paid' or 'already_paid', mark the failure resolved
 * with the transfer id. On 'waiting', change nothing. When it throws, add
 * one to the attempt count and store the error message.
 */
export async function retryTransfer(
  stripe: Stripe,
  destinationAccountId: string,
  failure: FailedTransfer,
): Promise<RetryResult> {
  assertMinorUnits(failure.amount, 'amount')
  if (failure.attempts >= MAX_TRANSFER_ATTEMPTS) return { status: 'gave_up' }

  const account = await stripe.accounts.retrieve(destinationAccountId)
  if (!account.charges_enabled || !account.payouts_enabled) return { status: 'waiting' }

  const prior = await stripe.transfers.list({
    transfer_group: failure.transferGroup,
    limit: 100,
  })
  const existing = prior.data.find((t) => t.destination === destinationAccountId)
  if (existing) return { status: 'already_paid', transfer: existing }

  const attempt = failure.attempts + 1
  const transfer = await stripe.transfers.create(
    {
      amount: failure.amount,
      currency: failure.currency.toLowerCase(),
      destination: destinationAccountId,
      transfer_group: failure.transferGroup,
      metadata: {
        ...failure.metadata,
        retry_of_failure: failure.id,
        retry_attempt: String(attempt),
      },
    },
    { idempotencyKey: `transfer-retry:${failure.id}:${attempt}` },
  )
  return { status: 'paid', transfer }
}
```

### src/webhooks.ts

```ts
import type Stripe from 'stripe'

export class WebhookSignatureError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'WebhookSignatureError'
  }
}

/**
 * Verify a webhook request and return the event.
 *
 * rawBody must be the exact bytes Stripe sent. If a JSON body parser has
 * already run, the signature will never match. In Express, mount the
 * webhook route with express.raw({ type: 'application/json' }) before
 * app.use(express.json()).
 *
 * Accepts several secrets because each endpoint has its own, and you may
 * need two endpoints on one URL: one listening to connected accounts and
 * one to your own account. An endpoint's connect setting cannot be changed
 * after it is created.
 */
export function verifyWebhook(
  stripe: Stripe,
  rawBody: string | Buffer,
  signature: string | null | undefined,
  secrets: ReadonlyArray<string>,
): Stripe.Event {
  if (!signature) throw new WebhookSignatureError('Missing Stripe-Signature header')
  const usable = secrets.filter(Boolean)
  if (usable.length === 0) throw new Error('No webhook signing secret configured')

  let lastError: unknown
  for (const secret of usable) {
    try {
      return stripe.webhooks.constructEvent(rawBody, signature, secret)
    } catch (err) {
      lastError = err
    }
  }
  throw new WebhookSignatureError(
    lastError instanceof Error ? lastError.message : 'Invalid signature',
  )
}

type EventOf<T extends Stripe.Event['type']> = Extract<Stripe.Event, { type: T }>

/** One handler per event type. Each gets the correctly typed event. */
export type WebhookHandlers = {
  [T in Stripe.Event['type']]?: (event: EventOf<T>) => Promise<void> | void
}

/**
 * - 'new':       this delivery claimed the event. Run the handler.
 * - 'done':      already processed. Answer 200 and do nothing.
 * - 'in_flight': another delivery is processing it right now. Answer 409 so
 *                Stripe comes back later, by which time it is done or failed.
 *
 * A record left by a failed attempt, or by a delivery that died mid-way
 * (deploy, crash) and has been untouched for a while, should be reclaimed
 * and return 'new', so Stripe's retry runs it again.
 */
export type ClaimState = 'new' | 'done' | 'in_flight'

export type WebhookHandlerOptions = {
  stripe: Stripe
  secrets: ReadonlyArray<string>
  handlers: WebhookHandlers
  /**
   * Stripe delivers at least once, and can send the same event twice at the
   * same moment. Claim it in one atomic step against a unique event id, e.g.
   *   INSERT INTO webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING
   * A SELECT then INSERT lets two deliveries both win.
   */
  claimEvent?: (event: Stripe.Event) => Promise<ClaimState>
  /** Mark the event processed after the handler succeeds. */
  completeEvent?: (event: Stripe.Event) => Promise<void>
  /**
   * Record the error after the handler throws. Keep the record and its
   * error: a failed record must be reclaimable, or Stripe's retry is
   * answered 200 as a duplicate and the event is lost.
   */
  failEvent?: (event: Stripe.Event, error: unknown) => Promise<void>
  onUnhandled?: (event: Stripe.Event) => Promise<void> | void
}

export type WebhookResult = {
  status: 200 | 400 | 409 | 500
  body: { received: boolean; duplicate?: boolean; error?: string }
}

/**
 * Build a framework-agnostic webhook handler. Give it the raw body and the
 * Stripe-Signature header; send back the status and body it returns.
 *
 * 2xx tells Stripe to stop. Anything else makes Stripe retry with backoff
 * for up to three days.
 */
export function createWebhookHandler(options: WebhookHandlerOptions) {
  return async function handle(
    rawBody: string | Buffer,
    signature: string | null | undefined,
  ): Promise<WebhookResult> {
    let event: Stripe.Event
    try {
      event = verifyWebhook(options.stripe, rawBody, signature, options.secrets)
    } catch (err) {
      if (err instanceof WebhookSignatureError) {
        return { status: 400, body: { received: false, error: err.message } }
      }
      throw err
    }

    const claim = options.claimEvent ? await options.claimEvent(event) : 'new'
    if (claim === 'done') {
      return { status: 200, body: { received: true, duplicate: true } }
    }
    if (claim === 'in_flight') {
      return { status: 409, body: { received: false, error: 'Event is being processed' } }
    }

    const handler = options.handlers[event.type] as
      | ((e: Stripe.Event) => Promise<void> | void)
      | undefined

    try {
      if (handler) await handler(event)
      else await options.onUnhandled?.(event)
      await options.completeEvent?.(event)
      return { status: 200, body: { received: true } }
    } catch (err) {
      await options.failEvent?.(event, err)
      return { status: 500, body: { received: false, error: 'Webhook handler failed' } }
    }
  }
}

/**
 * For events on connected accounts, event.account holds the acct_ id. Use it
 * to find which of your users the event is about. It is undefined for events
 * on your own platform account.
 */
export function connectedAccountId(event: Stripe.Event): string | null {
  return event.account ?? null
}
```

### src/idempotency.ts

```ts
import { createHash } from 'node:crypto'

// Stripe rejects idempotency keys longer than 255 characters.
const MAX_KEY_LENGTH = 255

/**
 * Build a deterministic idempotency key from a scope and the ids that make
 * the operation unique, e.g. idempotencyKey('payment', orderId).
 *
 * The same inputs always give the same key, so a retried request (your own
 * retry, a double tap, a replayed job) can never charge or transfer twice.
 * Long keys are hashed rather than cut short: cutting can make two different
 * operations share a key.
 */
export function idempotencyKey(
  scope: string,
  ...parts: Array<string | number>
): string {
  if (!scope) throw new Error('idempotencyKey needs a scope')
  const key = [scope, ...parts.map(String)].join(':')
  if (key.length <= MAX_KEY_LENGTH) return key
  const digest = createHash('sha256').update(key).digest('hex')
  return `${scope.slice(0, 100)}:sha256:${digest}`
}

/**
 * Key for an operation over a set of records, e.g. one transfer that pays
 * out several earnings rows. Order does not matter: the ids are sorted.
 */
export function idempotencyKeyForSet(
  scope: string,
  ids: ReadonlyArray<string>,
): string {
  if (ids.length === 0) throw new Error('idempotencyKeyForSet needs ids')
  return idempotencyKey(scope, [...ids].sort().join(','))
}
```

### src/testing.ts

```ts
import type Stripe from 'stripe'

/** True for sk_test_ and rk_test_ keys. */
export function isTestKey(secretKey: string): boolean {
  return /^(sk|rk)_test_/.test(secretKey)
}

/**
 * Throw unless the key is a test key. Call it at the top of seed scripts and
 * test helpers so they can never touch live money.
 */
export function assertTestMode(secretKey: string): void {
  if (!isTestKey(secretKey)) {
    throw new Error('Refusing to run: this needs a Stripe test key (sk_test_...)')
  }
}

/**
 * Values Stripe accepts in test mode when you send onboarding details over
 * the API. They verify an account without real people or documents.
 * https://docs.stripe.com/connect/testing
 */
export const TEST_ONBOARDING = {
  /** individual.dob: matches. 1902-01-01 matches immediately. */
  dob: { day: 1, month: 1, year: 1901 },
  /** address.line1, with a real city and postal code. */
  addressLine1: 'address_full_match',
  /** individual.id_number. */
  idNumber: '000000000',
  phone: '0000000000',
  /** Use as a file id in verification.document.front instead of uploading. */
  identityDocumentFile: 'file_identity_document_success',
  identityDocumentFileFailure: 'file_identity_document_failure',
  /** Irish IBAN that pays out. */
  ibanIreland: 'IE29AIBK93115212345678',
  usRoutingNumber: '110000000',
  usAccountNumber: '000123456789',
} as const

/** Card numbers for the payment side. Any future expiry, any CVC. */
export const TEST_CARDS = {
  success: '4242424242424242',
  declined: '4000000000000002',
  requiresAuthentication: '4000002500003155',
  /** Funds land straight in the available balance, handy for transfer tests. */
  availableBalance: '4000000000000077',
} as const

/**
 * Sign a payload the way Stripe does, so you can test your webhook endpoint
 * end to end without the network. Uses Stripe's own helper.
 */
export function signTestPayload(
  stripe: Stripe,
  payload: object,
  secret: string,
): { body: string; signature: string } {
  const body = JSON.stringify(payload)
  const signature = stripe.webhooks.generateTestHeaderString({
    payload: body,
    secret,
  })
  return { body, signature }
}
```

### examples/node-server.ts

```ts
/**
 * Minimal server wiring the kit together with node:http, no framework.
 * The app collects every detail on its own screens and posts them here; the
 * user never leaves the app and never sees Stripe.
 *
 * Swap the in-memory Maps for your database. Auth is left out: in a real app
 * every /connect route must check the signed-in user owns the account.
 *
 *   STRIPE_SECRET_KEY=... STRIPE_WEBHOOK_SECRET=... npx tsx examples/node-server.ts
 *   stripe listen --forward-connect-to localhost:4242/webhooks/stripe
 *
 * Or against the local emulator, with no Stripe account at all:
 *   STRIPE_HOST=localhost STRIPE_PORT=12111 STRIPE_PROTOCOL=http ...
 */
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
  createBankToken,
  createConnectedAccount,
  createStripeClient,
  createWebhookHandler,
  getAccountStatus,
  idempotencyKey,
  setPayoutBankAccount,
  submitIndividual,
  summarizeAccount,
  uploadVerificationDocument,
} from '../src/index.ts'
import type { Address, DateOfBirth } from '../src/index.ts'

const env = (name: string) => process.env[name] ?? ''
const stripe = createStripeClient(env('STRIPE_SECRET_KEY'), {
  host: env('STRIPE_HOST') || undefined,
  port: Number(env('STRIPE_PORT')) || undefined,
  protocol: env('STRIPE_PROTOCOL') === 'http' ? 'http' : undefined,
})

// userId -> acct_ id. Replace with a column on your users table.
const accounts = new Map<string, string>()
// Event id -> processed. Replace with a table with a unique key on the event id.
const events = new Map<string, boolean>()

const handleWebhook = createWebhookHandler({
  stripe,
  secrets: [env('STRIPE_WEBHOOK_SECRET'), env('STRIPE_WEBHOOK_SECRET_PLATFORM')],
  claimEvent: async (event) => {
    const processed = events.get(event.id)
    if (processed === true) return 'done'
    events.set(event.id, false)
    return 'new'
  },
  completeEvent: async (event) => {
    events.set(event.id, true)
  },
  failEvent: async (event, error) => {
    // Leave it unprocessed so Stripe's retry runs it again.
    console.error(`webhook ${event.id} failed`, error)
  },
  handlers: {
    'account.updated': async (event) => {
      // event.account is the acct_ id. Store the summary against your user.
      const status = summarizeAccount(event.data.object)
      console.log(`${event.account}: ${status.state}, next steps: ${status.steps.join(', ')}`)
    },
  },
})

type OnboardBody = {
  firstName: string
  lastName: string
  phone?: string
  dob: DateOfBirth
  address: Address
  idNumber: string
  iban: string
  accountHolderName: string
  /** When the user ticked "I agree", in milliseconds. */
  tosAcceptedAt: number
}

/** Create the account once, send everything the form collected, return what is still due. */
async function onboard(req: IncomingMessage, userId: string, body: OnboardBody) {
  let accountId = accounts.get(userId)
  if (!accountId) {
    const account = await createConnectedAccount(stripe, {
      country: body.address.country,
      businessType: 'individual',
      capabilities: ['transfers'],
      metadata: { user_id: userId },
      idempotencyKey: idempotencyKey('account', userId),
    })
    accountId = account.id
    accounts.set(userId, accountId)
  }

  await submitIndividual(stripe, accountId, {
    firstName: body.firstName,
    lastName: body.lastName,
    phone: body.phone,
    dob: body.dob,
    address: body.address,
    idNumber: body.idNumber,
    tos: {
      acceptedAt: body.tosAcceptedAt,
      // The IP comes from the request, not the app: phones don't know their public IP.
      ip: clientIp(req),
      userAgent: String(req.headers['user-agent'] ?? 'unknown'),
    },
  })

  const token = await createBankToken(stripe, {
    accountHolderName: body.accountHolderName,
    accountHolderType: 'individual',
    accountNumber: body.iban,
    country: body.address.country,
    currency: 'eur',
  })
  const bank = await setPayoutBankAccount(stripe, accountId, token)

  // Read it back now rather than waiting for the webhook.
  const status = await getAccountStatus(stripe, accountId)
  return { status, bankLast4: bank.last4 }
}

function clientIp(req: IncomingMessage): string {
  const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim()
  return forwarded || req.socket.remoteAddress || ''
}

async function readBody(req: IncomingMessage): Promise<Buffer> {
  const chunks: Array<Buffer> = []
  for await (const chunk of req) chunks.push(chunk as Buffer)
  return Buffer.concat(chunks)
}

function json(res: ServerResponse, status: number, body: unknown) {
  res.writeHead(status, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify(body))
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  // Stand-in for real auth.
  const userId = String(req.headers['x-user-id'] ?? 'demo-user')

  try {
    if (req.method === 'POST' && url.pathname === '/webhooks/stripe') {
      const result = await handleWebhook(await readBody(req), req.headers['stripe-signature'] as string)
      return json(res, result.status, result.body)
    }

    if (req.method === 'POST' && url.pathname === '/connect/onboard') {
      const body = JSON.parse((await readBody(req)).toString('utf8')) as OnboardBody
      return json(res, 200, await onboard(req, userId, body))
    }

    if (req.method === 'POST' && url.pathname === '/connect/identity-document') {
      const accountId = accounts.get(userId)
      if (!accountId) return json(res, 400, { error: 'Onboard first' })
      const side = url.searchParams.get('side') === 'back' ? 'back' : 'front'
      await uploadVerificationDocument(stripe, accountId, {
        data: await readBody(req),
        type: String(req.headers['content-type'] ?? 'image/jpeg'),
        side,
        purpose: 'identity_document',
      })
      return json(res, 200, await getAccountStatus(stripe, accountId))
    }

    if (req.method === 'GET' && url.pathname === '/connect/status') {
      const accountId = accounts.get(userId)
      if (!accountId) return json(res, 200, { state: 'not_started' })
      return json(res, 200, await getAccountStatus(stripe, accountId))
    }

    json(res, 404, { error: 'Not found' })
  } catch (err) {
    // Stripe validates every update and says which field is wrong. Pass it on.
    const message = err instanceof Error ? err.message : 'Something went wrong'
    json(res, 400, { error: message })
  }
})

server.listen(4242, () => console.log('Listening on http://localhost:4242'))
```

### examples/PayoutDetailsForm.tsx

```tsx
/**
 * React Native form that collects payout details in the app and posts them
 * to your server, which calls the kit (see examples/node-server.ts). No
 * Stripe page, no browser: the user stays in the app.
 *
 * Kept to core components so it drops into any project. Split it into one
 * screen per step if you like; summarizeAccount(...).steps tells you which
 * steps are still needed.
 */
import { useState } from 'react'
import { Pressable, ScrollView, Switch, Text, TextInput, View } from 'react-native'

const SERVER_URL = 'https://api.example.com'

type Props = {
  /** Your auth token for the signed-in user. */
  token: string
  onDone: (status: { state: string }) => void
}

export function PayoutDetailsForm({ token, onDone }: Props) {
  const [form, setForm] = useState({
    firstName: '',
    lastName: '',
    dob: '', // DD/MM/YYYY
    line1: '',
    city: '',
    postalCode: '',
    idNumber: '',
    accountHolderName: '',
    iban: '',
  })
  const [acceptedAt, setAcceptedAt] = useState<number | null>(null)
  const [error, setError] = useState('')
  const [submitting, setSubmitting] = useState(false)

  const set = (key: keyof typeof form) => (value: string) =>
    setForm((f) => ({ ...f, [key]: value }))

  async function submit() {
    const [day, month, year] = form.dob.split('/').map((n) => parseInt(n, 10))
    const iban = form.iban.replace(/\s/g, '').toUpperCase()
    if (!day || !month || !year) return setError('Enter your date of birth as DD/MM/YYYY')
    if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) return setError('Check your IBAN')
    if (!acceptedAt) return setError('You need to accept the terms to get paid')

    setSubmitting(true)
    setError('')
    try {
      const res = await fetch(`${SERVER_URL}/connect/onboard`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
        body: JSON.stringify({
          firstName: form.firstName.trim(),
          lastName: form.lastName.trim(),
          dob: { day, month, year },
          address: { line1: form.line1, city: form.city, postalCode: form.postalCode, country: 'IE' },
          idNumber: form.idNumber,
          accountHolderName: form.accountHolderName.trim(),
          iban,
          // When they ticked the box. The server adds the IP and user agent.
          tosAcceptedAt: acceptedAt,
        }),
      })
      const body = await res.json()
      // Stripe's validation message names the field, so show it as it is.
      if (!res.ok) throw new Error(body.error ?? 'Could not save your details')
      onDone(body.status)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Could not save your details')
    } finally {
      setSubmitting(false)
    }
  }

  const field = (label: string, key: keyof typeof form, props: object = {}) => (
    <View style={{ marginBottom: 12 }}>
      <Text>{label}</Text>
      <TextInput
        value={form[key]}
        onChangeText={set(key)}
        style={{ borderWidth: 1, borderRadius: 8, padding: 12 }}
        {...props}
      />
    </View>
  )

  return (
    <ScrollView contentContainerStyle={{ padding: 16 }}>
      {field('First name', 'firstName', { autoComplete: 'given-name' })}
      {field('Last name', 'lastName', { autoComplete: 'family-name' })}
      {field('Date of birth', 'dob', { placeholder: 'DD/MM/YYYY', keyboardType: 'number-pad' })}
      {field('Home address', 'line1', { autoComplete: 'street-address' })}
      {field('Town or city', 'city')}
      {field('Postcode', 'postalCode', { autoCapitalize: 'characters' })}
      {field('Tax ID number', 'idNumber', { autoCapitalize: 'characters' })}
      {field('Account holder name', 'accountHolderName')}
      {field('IBAN', 'iban', { autoCapitalize: 'characters', autoCorrect: false })}

      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginVertical: 12 }}>
        <Switch
          value={acceptedAt !== null}
          onValueChange={(on) => setAcceptedAt(on ? Date.now() : null)}
        />
        <Text style={{ flex: 1 }}>
          I agree to the Stripe Connected Account Agreement, which includes the Stripe Services
          Agreement.
        </Text>
      </View>

      {error ? <Text style={{ marginBottom: 12 }}>{error}</Text> : null}

      <Pressable
        onPress={submit}
        disabled={submitting}
        style={{ backgroundColor: '#111', borderRadius: 12, padding: 16, opacity: submitting ? 0.6 : 1 }}
      >
        <Text style={{ color: '#fff', textAlign: 'center', fontWeight: '600' }}>
          {submitting ? 'Saving' : 'Save and continue'}
        </Text>
      </Pressable>
    </ScrollView>
  )
}
```

### .env.example

```sh
# Copy to .env and fill in. Never commit the real values.

# Secret key from the Stripe dashboard. Use the sk_test_ key while building.
STRIPE_SECRET_KEY=sk_test_replace_me

# Signing secret for your webhook endpoint (Dashboard > Developers > Webhooks,
# or printed by `stripe listen --forward-connect-to localhost:4242/webhooks/stripe`).
STRIPE_WEBHOOK_SECRET=whsec_replace_me

# Optional second signing secret. Needed if you run two endpoints at the same
# URL: one for events on connected accounts and one for your own account.
STRIPE_WEBHOOK_SECRET_PLATFORM=

# Optional: point the client at the local Stripe emulator instead of Stripe.
STRIPE_HOST=
STRIPE_PORT=
STRIPE_PROTOCOL=
```

## Licence

MIT. Use it in anything, commercial or not. No warranty: this moves real money, so read it and test it in test mode before you rely on it.

Licence text:

```text
MIT License

Copyright (c) 2026 David Ewins

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
