Payments
How 0% commission works: Stripe Connect and money that never touches us
A walk through how Bookatu takes a client deposit straight into the salon's own Stripe balance with a direct charge on a connected account, so the platform never holds the money.
Bookatu charges 0% commission on bookings. That is easy to put on a pricing page and surprisingly load-bearing in the code. The promise is not just that we don't take a cut. It is stronger. The client's money never lands in a Bookatu account at all. A deposit paid through our booking page goes straight into the salon's own Stripe balance, and from there to their bank. We are not in the flow of funds.
That reads like a marketing line, but it is mostly an architecture decision, and it shapes a lot of choices downstream. If money flowed through us, we would be a money transmitter, we would own every chargeback, and we would owe each tenant a payout we were holding. Keeping the money out of our hands removes all three problems at once. Here is how the pieces fit together.
The gist
- Each tenant connects their OWN Stripe account. We never create a balance we owe them.
- Deposits are DIRECT charges on the connected account: no transfer_data, no on_behalf_of, no application_fee. The salon is merchant of record.
- One platform webhook endpoint receives connected-account events, verified and idempotent, and only records state. It never moves money.
- If a tenant hasn't connected Stripe, we skip the deposit rather than collect into our account. The booking still confirms.
The tenant brings their own Stripe
Every salon connects a Stripe Express account through standard Connect onboarding. We create (or reuse) an account on the platform, then hand the operator a one-time onboarding link. The id we store against the org is the whole relationship. It is how we later route a charge to them.
// createConnectAccountLink (simplified)
let accountId = org.stripeAccountId;
if (!accountId) {
const account = await stripe.accounts.create({
type: "express",
metadata: { orgId },
});
accountId = account.id;
await db.update(organizations)
.set({ stripeAccountId: accountId })
.where(eq(organizations.id, orgId));
}
const link = await stripe.accountLinks.create({
account: accountId,
type: "account_onboarding",
return_url: connectReturnUrl(org), // our return endpoint
refresh_url: connectRefreshUrl(org), // our connect endpoint
});When Stripe sends the operator back, we re-read the account and store whether it can actually take money. The one field that matters is charges_enabled. Onboarding is multi-step and asynchronous, so the account id existing means nothing on its own. We treat "can take payments" as charges_enabled being true, and nothing else.
// refreshConnectStatus (simplified)
const account = await stripe.accounts.retrieve(org.stripeAccountId);
const chargesEnabled = account.charges_enabled ?? false;
await db.update(organizations)
.set({ stripeChargesEnabled: chargesEnabled, stripeAccountId: account.id })
.where(eq(organizations.id, orgId));A deposit is a direct charge, not a transfer
This is the part that makes 0% real. When a client pays a deposit, we build an ordinary Stripe Checkout session, but we create it on a Stripe client bound to the tenant's connected account. Binding the account sends the Stripe-Account header on every request, which is what turns a normal charge into a direct charge on their account.
// The connected-account client
export async function forConnectedAccount(accountId: string) {
const { default: Stripe } = await import("stripe");
return new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
stripeAccount: accountId, // == the Stripe-Account header
});
}
// createCheckoutSession (simplified)
const stripe =
org.stripeAccountId && org.stripeChargesEnabled
? await forConnectedAccount(org.stripeAccountId)
: await getPlatformStripe();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [/* deposit line item in the org's currency */],
// no transfer_data, no on_behalf_of, no application_fee_amount
payment_intent_data: { metadata: { appointmentId: apt.id } },
metadata: { appointmentId: apt.id, orgId: apt.orgId, ref: apt.ref },
});What is absent here matters more than what is present. There is no transfer_data and no on_behalf_of, so this is not a destination charge with us in the middle. There is no application_fee_amount, so we take nothing off the top. The funds, the Stripe fees, and the dispute liability all sit with the tenant, because it is their balance and they are the merchant of record. That is the 0% commission, expressed in what the session-create call does not say.
The cleanest way to never owe someone a payout is to never receive their money in the first place.
If they haven't connected Stripe, skip the deposit
Now the awkward case: a tenant who has not finished Stripe onboarding. The lazy path would be to collect that deposit into our own platform account and reconcile later. We deliberately don't, because the moment we hold a client's money on behalf of a business, we are a money transmitter and we owe that business a payout. So there is a gate, on by default, with a deliberately plain rule.
export function canTakePayments(org) {
return Boolean(org.stripeAccountId && org.stripeChargesEnabled);
}
// A separate config gate, on by default, decides the unconnected case:
// tenants without Stripe must NOT collect into the platform account.The decision is made at the source, in the booking flow, not at the payment step. If the gate is on and the tenant can't take payments, we don't create a pending appointment that waits on a card. We book it confirmed with no deposit. The client is never stopped, never shown an error, never left holding a half-finished checkout. The business simply can't take a card deposit until they connect Stripe.
- The gate is decided once, in createBooking, so book actions never even call the checkout path for an unconnected org.
- createCheckoutSession repeats the same check and returns null as defence in depth, so a stray direct call still can't take platform money.
- Wallet-credit deposits (gift cards, packages, memberships) are unaffected. They spend the client's existing balance and move no platform money.
- Connecting a charges-enabled account is also our "real business" signal: it auto-publishes a tenant that was held hidden by the anti-spam default.
One webhook, on the platform, that only records
Funds living on the tenant's account does not mean the webhook does. Connect delivers connected-account events to the platform endpoint with event.account set, signed with the platform signing secret. So we run a single webhook for our own platform events and every tenant's events, and verify them all the same way. We read the raw bytes and check the signature before parsing any JSON, because Stripe signs exactly the bytes it sent.
When a deposit's checkout.session.completed arrives, the handler does the smallest possible thing. It flips the appointment to confirmed and records the payment intent id. It does not move money, because the money already moved, directly, into the tenant's balance. The webhook is bookkeeping, not banking.
// our webhook handler - deposit branch (simplified)
if (session.metadata?.appointmentId) {
const paymentIntentId = typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null;
if (paymentIntentId) {
await markDepositPaid(
session.metadata.appointmentId,
paymentIntentId,
session.amount_total ?? 0,
);
}
}
await ledgerSession(session); // reconciliation record, not a fund transferTwo properties keep this safe under Stripe's retries. First, dedup: an event id we have already processed returns 200 and does no work, so a redelivery is a no-op. Second, idempotent writes: markDepositPaid refuses to resurrect an appointment that was already cancelled, no-show or completed, and is a no-op if the deposit is already marked paid. If the handler throws partway through, we return 5xx and record nothing, so Stripe redelivers and we re-run writes that are safe to re-run. The event id is stored only after the handler succeeds.
The same pattern, kept honest as we grow
Once deposits work this way, every other thing a salon sells should too. The storefront checkouts (products, gift cards, packages, memberships) reuse the deposit mechanism through a single routing module, behind a flag, so we can move them off the platform account and onto each tenant's connected account without touching the webhook. That module does leave room for an optional platform fee, expressed in basis points, defaulted to zero. At zero it is inert and the routed path is the unfee'd direct charge. If we ever charge a margin on a specific product surface, it will be a deliberate, visible number, not a quiet cut of every booking.
It is worth being clear about what 0% commission costs us. We give up the easiest revenue lever in this category, the per-booking take rate, and we make onboarding a hard dependency: a tenant has to finish Stripe before they can charge a card. We make money instead from a subscription priced on active clients in the last 90 days, not per seat, with every feature on every plan. The booking flow stays out of the flow of funds. So far that trade has been worth it, mostly because the version where we hold other people's money is a much harder system to get right, and a much worse one to be wrong about.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs