David Ewins

Stripe Connect kit

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

src/index.tsShow
src/index.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.tsShow
src/client.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.tsShow
src/accounts.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.tsShow
src/onboarding.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.tsShow
src/bank.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.tsShow
src/documents.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.tsShow
src/identity.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.tsShow
src/sessions.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.tsShow
src/status.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.tsShow
src/requirements.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.tsShow
src/fees.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.tsShow
src/payments.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.tsShow
src/retry.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.tsShow
src/webhooks.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.tsShow
src/idempotency.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.tsShow
src/testing.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.tsShow
examples/node-server.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.tsxShow
examples/PayoutDetailsForm.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.exampleShow
.env.example
# 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 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.