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.
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 keysubmitIndividualandsubmitCompany: 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 APIcreateBankTokenandsetPayoutBankAccount: the payout bank attached as a token, with the last four digits read back from StripeuploadVerificationDocumentandcreateIdentitySession: the ID document, uploaded by your server or captured with Stripe IdentitygetAccountStatus: charges and payouts on or off, and what is still due as plain English labels, each with the step in your app that collects itcreatePlatformPayment,createTransferandcreatePaymentWithFee: separate charges and transfers, or destination charges with an application feereverseForRefundandretryTransfer: a proportional clawback when an order is refunded, and safe retries for transfers that failedcreateWebhookHandler: 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
npm install stripe
cp .env.example .env # then fill in your test keysThen 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_updatelink 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
- 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.
- The app posts everything to my server in one request.
- 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.
- 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.
- 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.
- The app asks for an ID document. In my seller app that is Stripe Identity's camera flow, linked to the account's representative.
- Stripe verifies everything in the background and sends
account.updated. Whencharges_enabledandpayouts_enabledare both true, the account can be paid.
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:
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.
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.
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.
// 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.
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.
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.
'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.
// 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.
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 offcapability.updated: a capability switched offidentity.verification_session.verifiedandidentity.verification_session.requires_input: the ID check passed or needs another trypayment_intent.succeeded,payment_intent.payment_failedandpayment_intent.canceled: mark orders paid here, not when the app says the payment went throughcharge.refunded: reverse the seller's sharecharge.dispute.createdandcharge.dispute.closed: a chargeback, with a deadlinepayout.paidandpayout.failed: money reached the user's bank, or the bank details are wrongtransfer.createdandtransfer.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:
stripe listen \
--forward-to localhost:4242/webhooks/stripe \
--forward-connect-to localhost:4242/webhooks/stripeOr 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.
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
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.tsShowHide
/**
* 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.tsxShowHide
/**
* 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.exampleShowHide
# 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
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.