| name | stripe-connect |
| description | Build marketplace and platform payment flows with Stripe Connect. Use when: building two-sided marketplaces, splitting payments between buyers and sellers, onboarding sellers/providers to accept payments, handling platform fees and payouts, or managing connected accounts for a platform. |
| license | Apache-2.0 |
| compatibility | Requires Node.js 18+, Stripe account with Connect enabled |
| metadata | {"author":"terminal-skills","version":"1.1.0","category":"business","tags":["stripe","stripe-connect","marketplace","payments","platform"],"use-cases":["Onboard freelancers to accept payments on a service marketplace","Charge buyers and split payment between platform and seller","Create Express connected accounts with Stripe's hosted onboarding","Handle automatic payouts to sellers after service completion","Listen for Connect webhooks to track account and payment status"],"agents":["claude-code","openai-codex","gemini-cli","cursor"]} |
Stripe Connect
Overview
Stripe Connect routes payments between buyers, sellers, and your platform — handling regulatory compliance (KYC), payouts, and tax reporting for connected accounts.
This skill uses the Accounts v2 API (/v2/core/accounts), Stripe's recommended path for new Connect platforms. A v2 account is shaped by the configurations you assign to it instead of a v1 account type + flat capabilities:
| Configuration | Enables | Key capability |
|---|
| merchant | Accept payments (direct charges) | card_payments |
| recipient | Receive transfers / destination payouts | stripe_balance.stripe_transfers |
| customer | Be billed as a customer (subscriptions, invoices) | automatic_indirect_tax |
Dashboard access is a separate dashboard field that replaces the old standard/express/custom type:
dashboard | ≈ v1 type | Onboarding | Best for |
|---|
"express" | Express | Stripe-hosted | Marketplaces (recommended) |
"full" | Standard | Stripe-hosted / OAuth | Sellers wanting a full Stripe dashboard |
"none" | Custom | Embedded / your UI | Platforms needing full control |
Accounts v2 requires opt-in registration in the Dashboard (Connect settings). Accounts v1 (stripe.accounts.create({ type: "express" })) is still fully GA — see the legacy note in step 1 if you haven't registered.
Charge types (the payment APIs are v1 and reference the connected account by id):
| Type | Who pays Stripe fees | Seller config needed | Use when |
|---|
| Direct | Connected account | merchant | Seller wants full control |
| Destination | Platform | recipient | Platform manages UX |
| Separate charges + transfers | Platform | recipient | Complex routing |
Setup
npm install stripe
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-05-27.dahlia",
});
Onboard Sellers (Accounts v2)
1. Create a connected account
Assign merchant (accept payments) and recipient (receive payouts/transfers). dashboard: "express" gives sellers Stripe's hosted Express dashboard.
import { stripe } from "@/lib/stripe";
export async function createSellerAccount(email: string) {
const account = await stripe.v2.core.accounts.create({
contact_email: email,
display_name: email,
dashboard: "express",
identity: {
country: "us",
entity_type: "individual",
},
configuration: {
merchant: {
capabilities: { card_payments: { requested: true } },
},
recipient: {
capabilities: {
stripe_balance: { stripe_transfers: { requested: true } },
},
},
},
defaults: {
currency: "usd",
responsibilities: {
fees_collector: "application",
losses_collector: "application",
},
},
});
return account.id;
}
2. Generate onboarding link (Account Links v2)
export async function createOnboardingLink(accountId: string, userId: string) {
const accountLink = await stripe.v2.core.accountLinks.create({
account: accountId,
use_case: {
type: "account_onboarding",
account_onboarding: {
configurations: ["merchant", "recipient"],
refresh_url: `${process.env.BASE_URL}/sellers/onboard/refresh?userId=${userId}`,
return_url: `${process.env.BASE_URL}/sellers/onboard/complete?userId=${userId}`,
},
},
});
return accountLink.url;
}
app.post("/api/sellers/onboard", async (req, res) => {
const { email, userId } = req.body;
const accountId = await createSellerAccount(email);
await db.sellers.update(userId, { stripeAccountId: accountId });
const url = await createOnboardingLink(accountId, userId);
res.json({ url });
});
3. Check onboarding status
export async function isSellerOnboarded(accountId: string): Promise<boolean> {
const account = await stripe.v2.core.accounts.retrieve(accountId, {
include: ["requirements"],
});
return (account.requirements?.currently_due?.length ?? 0) === 0;
}
app.get("/sellers/onboard/complete", async (req, res) => {
const { userId } = req.query;
const seller = await db.sellers.findById(userId);
const onboarded = await isSellerOnboarded(seller.stripeAccountId);
if (onboarded) {
await db.sellers.update(userId, { status: "active" });
res.redirect("/dashboard?onboarded=true");
} else {
res.redirect("/sellers/onboard/pending");
}
});
Charging Buyers
Destination charges and transfers require the seller's recipient configuration; direct charges require merchant. The PaymentIntents / Transfers APIs themselves are unchanged.
Destination Charges (Platform collects, sends to seller)
export async function chargeWithDestination({
amount, // in cents
currency = "usd",
paymentMethodId,
customerId,
sellerAccountId,
platformFeePercent = 15,
}: {
amount: number;
currency?: string;
paymentMethodId: string;
customerId: string;
sellerAccountId: string;
platformFeePercent?: number;
}) {
const platformFee = Math.round(amount * (platformFeePercent / 100));
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
customer: customerId,
payment_method: paymentMethodId,
confirm: true,
transfer_data: {
destination: sellerAccountId,
},
application_fee_amount: platformFee,
automatic_payment_methods: { enabled: true, allow_redirects: "never" },
});
return paymentIntent;
}
Direct Charges (Seller's Stripe account)
export async function directCharge({
amount,
paymentMethodId,
sellerAccountId,
platformFeePercent = 10,
}: {
amount: number;
paymentMethodId: string;
sellerAccountId: string;
platformFeePercent?: number;
}) {
const platformFee = Math.round(amount * (platformFeePercent / 100));
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: "usd",
payment_method: paymentMethodId,
confirm: true,
application_fee_amount: platformFee,
},
{
stripeAccount: sellerAccountId,
}
);
return paymentIntent;
}
Separate Charges + Transfers (most flexible)
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000,
currency: "usd",
payment_method: paymentMethodId,
confirm: true,
});
export async function payoutToSeller(
paymentIntentId: string,
sellerAccountId: string,
amount: number
) {
const transfer = await stripe.transfers.create({
amount,
currency: "usd",
destination: sellerAccountId,
source_transaction: paymentIntentId,
});
return transfer;
}
Payouts
Automatic payouts (default)
By default, Stripe pays out to sellers on their payout schedule — no extra code needed. The schedule is configured on the connected account (its Express dashboard or the Accounts API).
Manual / triggered payouts
export async function triggerPayout(sellerAccountId: string, amount: number) {
const payout = await stripe.payouts.create(
{
amount,
currency: "usd",
method: "instant",
},
{
stripeAccount: sellerAccountId,
}
);
return payout;
}
Check seller balance
export async function getSellerBalance(sellerAccountId: string) {
const balance = await stripe.balance.retrieve({
stripeAccount: sellerAccountId,
});
return {
available: balance.available[0]?.amount ?? 0,
pending: balance.pending[0]?.amount ?? 0,
};
}
Webhooks for Connect
Connect webhooks can fire for your platform account or for events on connected accounts.
import { stripe } from "@/lib/stripe";
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
const connectedAccountId = (event as any).account as string | undefined;
switch (event.type) {
case "account.updated": {
const account = event.data.object as Stripe.Account;
if (account.details_submitted) {
await db.sellers.update(
{ stripeAccountId: account.id },
{ status: "active" }
);
}
break;
}
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
await db.orders.update(
{ stripePaymentIntentId: pi.id },
{ status: "paid" }
);
break;
}
case "payment_intent.payment_failed": {
const pi = event.data.object as Stripe.PaymentIntent;
await db.orders.update(
{ stripePaymentIntentId: pi.id },
{ status: "failed", failureReason: pi.last_payment_error?.message }
);
break;
}
case "transfer.created": {
const transfer = event.data.object as Stripe.Transfer;
console.log(`Transferred ${transfer.amount} to ${transfer.destination}`);
break;
}
case "payout.paid": {
const payout = event.data.object as Stripe.Payout;
console.log(`Payout ${payout.id} paid to ${connectedAccountId}`);
break;
}
case "charge.dispute.created": {
const dispute = event.data.object as Stripe.Dispute;
await handleDispute(dispute);
break;
}
}
res.json({ received: true });
});
Listen to connected account events
const webhookEndpoint = await stripe.webhookEndpoints.create({
url: "https://myapp.com/webhooks/stripe",
enabled_events: [
"account.updated",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"transfer.created",
"payout.paid",
"charge.dispute.created",
],
connect: true,
});
Accounts v2 events (thin events)
v2 accounts emit thin events to an event destination (configured in the Dashboard or via the API), not the classic v1 webhook. Listen for v2.core.account[requirements].updated to track onboarding:
app.post("/webhooks/stripe/v2", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
const notification = stripe.parseEventNotification(
req.body, sig, process.env.STRIPE_V2_WEBHOOK_SECRET!
);
if (notification.type === "v2.core.account[requirements].updated") {
const account = await notification.fetchRelatedObject();
const done = (account.requirements?.currently_due?.length ?? 0) === 0;
await db.sellers.update(
{ stripeAccountId: account.id },
{ status: done ? "active" : "onboarding" }
);
}
res.json({ received: true });
});
Refunds
export async function refundPayment(
paymentIntentId: string,
amount?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
) {
const refund = await stripe.refunds.create({
payment_intent: paymentIntentId,
...(amount && { amount }),
...(reason && { reason }),
refund_application_fee: true,
reverse_transfer: true,
});
return refund;
}
OAuth for Standard Accounts
If you authenticate connected accounts with OAuth, keep using the v1 Accounts API — Accounts v2 does not replace the OAuth flow.
export function getStripeOAuthUrl(state: string) {
return `https://connect.stripe.com/oauth/authorize?` +
`response_type=code&client_id=${process.env.STRIPE_CLIENT_ID}` +
`&scope=read_write&state=${state}`;
}
app.get("/auth/stripe/callback", async (req, res) => {
const { code, state } = req.query;
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code: code as string,
});
const connectedAccountId = response.stripe_user_id!;
res.redirect("/dashboard");
});
Testing
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
Environment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # v1 webhook endpoint
STRIPE_V2_WEBHOOK_SECRET=whsec_... # v2 event destination (Accounts v2)
STRIPE_CLIENT_ID=ca_... # Only for Standard OAuth
BASE_URL=http://localhost:3000