How we built per-artist Stripe Connect onboarding, checkout sessions with platform fees, webhook-driven fulfillment, and digital download delivery into our multi-tenant platform.
Our platform is multi-tenant — each artist gets their own portfolio site on a subdomain or custom domain. When we decided to let collectors buy artwork directly, we needed a payment system where each artist receives payouts to their own bank account while the platform takes a fee. Stripe Connect was the natural fit. Here is how we built it.
The architecture at a glance
The payment flow has four layers: Stripe Connect onboarding (artist sets up their payout account), checkout session creation (buyer clicks Buy Now), webhook processing (Stripe tells us the payment succeeded), and fulfillment (mark artwork as sold, send download links, email the customer). The backend is NestJS with Prisma, and the frontend is Next.js.
Stripe Connect onboarding
Each tenant (artist) needs a Stripe Connect Express account. When an artist visits their billing settings and clicks “Set up payouts,” we create a Connect account and generate an account link for Stripe’s hosted onboarding flow.
// stripe-connect.service.ts
async createAccountLink(tenantId: string, email: string) {
let connectAccountId = tenant.stripeConnectAccountId;
if (!connectAccountId) {
const account = await this.stripe.accounts.create({
type: 'express',
country: 'US',
email: email || undefined,
business_type: 'individual',
metadata: { tenant_id: tenantId },
});
connectAccountId = account.id;
await this.prisma.tenant.update({
where: { id: tenantId },
data: { stripeConnectAccountId: connectAccountId },
});
}
const accountLink = await this.stripe.accountLinks.create({
account: connectAccountId,
refresh_url: `${FRONTEND_URL}/admin/billing`,
return_url: `${FRONTEND_URL}/admin/billing?connect=success`,
type: 'account_onboarding',
});
return accountLink;
}We store the stripeConnectAccountId on the Tenant record. A status endpoint checks whether charges and payouts are enabled so the frontend can show the artist where they are in the process.
One gotcha: if the platform owner has not completed Stripe Connect settings (specifically the “managing losses” acknowledgment), account creation fails with an opaque error. We catch this and surface a helpful message pointing to the Stripe dashboard.
Checkout session creation with platform fees
When a collector clicks Buy Now on an artwork page, the frontend POSTs to /payments/checkout-session with the tenant slug, customer email, and line items. The controller validates each item, creates an Order record in the database, then creates a Stripe Checkout Session with a platform fee.
// stripe-checkout.service.ts
const params: Stripe.Checkout.SessionCreateParams = {
payment_method_types: ['card'],
mode: 'payment',
customer_email: input.customerEmail,
line_items: lineItems.map(item => ({
price_data: {
product_data: { name: item.title },
unit_amount: item.priceCents,
currency: 'usd',
},
quantity: item.quantity,
})),
success_url: `${tenantUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${tenantUrl}/checkout/cancel`,
metadata: { order_id: order.id, tenant_id: tenant.id },
phone_number_collection: { enabled: true },
billing_address_collection: 'required',
shipping_address_collection: { allowed_countries: ['US'] },
payment_intent_data: {
application_fee_amount: input.platformFeeCents,
transfer_data: {
destination: input.connectAccountId,
},
},
};
return this.stripe.checkout.sessions.create(params);The key part is payment_intent_data: we set application_fee_amount (15% of the total) and transfer_data.destination to the artist’s Connect account. Stripe handles the split — the platform gets its fee, the artist gets the rest, and we never touch the money directly.
We create the Order record before the Stripe session and store the session ID back on the order afterward. This way, even if something goes wrong, we have a paper trail of every checkout attempt.
Webhook handling with a handler registry
Stripe sends webhook events to /payments/webhook. Rather than a giant switch statement, we use a simple handler registry pattern. Each handler implements an interface with an event type and a handle method:
// webhook-handler.interface.ts
export interface IWebhookHandler {
readonly eventType: string;
handle(event: Stripe.Event): Promise<void>;
}
// stripe-webhook.service.ts
registerHandler(handler: IWebhookHandler): void {
this.handlerMap.set(handler.eventType, handler);
}
async processWebhook(sig: string, body: any): Promise<void> {
const event = this.stripe.webhooks.constructEvent(
body, sig, this.config.webhookSecret,
);
if (this.processedWebhookEvents.has(event.id)) {
this.logger.warn(`Duplicate webhook event ignored: ${event.id}`);
return;
}
const handler = this.handlerMap.get(event.type);
if (handler) {
await handler.handle(event);
}
this.processedWebhookEvents.add(event.id);
}We register four handlers on module init: checkout.session.completed, payment_intent.payment_failed, invoice.paid, and invoice.payment_failed. The in-memory Set prevents duplicate processing — Stripe sometimes sends the same event twice. When the set grows past 1,000 entries, we trim it to the most recent 500.
Fulfillment: paintings, provenance, and downloads
The checkout.session.completed handler does the heavy lifting. Inside a Prisma transaction, it:
- Marks the order as
paidand stores the payment intent ID and shipping address - Marks sold paintings as
isAvailable: false - Creates a provenance record with the sale price and event type “sold”
- Decrements inventory for physical products
- Generates a cryptographically random download token for digital products (PDF/EPUB), valid for 7 days with a 5-download cap
After the transaction, we send two emails: a payment confirmation and a download links email (if the order includes digital products). We also fire off a Telegram notification so we know about every sale in real time.
Frontend: BuyNowButton and payment verification
On the artwork detail page, the Buy Now button only renders if the artwork is available and the tenant has a Connect account. When clicked, it calls the checkout endpoint and redirects to the Stripe-hosted checkout page.
The success page does not just show a thank-you message. It calls back to our /payments/checkout-session/:id endpoint to verify the payment status, then polls for order items with download tokens. This handles the race condition where the webhook has not finished processing by the time the user lands on the success page — we retry up to 6 times with 2-second intervals.
Free gifts and edge cases
We also built a free gift system: tenants can configure gift products at the tenant level or per painting, with a max gift limit. The checkout controller validates that selected gift IDs are in the allowed set, enforces the max, and adds them as zero-price line items. If a gift product is also in the cart as a paid item, we skip the duplicate.
What we learned
A few takeaways from this integration:
- Create the order before the Stripe session. If session creation fails, you still have a record of the intent.
- Use metadata to link everything. We pass
order_idandtenant_idin session metadata. The webhook handler reads it to find the order without extra lookups. - Handle webhook duplicates. Stripe will resend events. An in-memory Set is fine for a single instance; for multi-instance deployments, use Redis.
- Poll on the success page. Webhooks are asynchronous. The user may land on your success page before fulfillment completes. Polling bridges that gap.
- Catch Stripe platform configuration errors early. The “managing losses” error is common when first setting up Connect. Surface it clearly.
The result
Artists can onboard with Stripe in a few clicks, set prices on their work, and collectors can buy directly from the portfolio site. The platform takes its cut automatically, paintings are marked sold with full provenance, and digital product buyers get their download links by email and on the confirmation page. The whole flow runs through Stripe’s hosted checkout, so we never handle card data directly.
Welcome to The infinite monkey theorem
Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

