Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Zwischen Oktober 2025 und März 2026 habe ich einen B2B-Wiederverkaufs-Marktplatz für eine Nischenbranche im Industriebedarf entwickelt — vier angebundene Anbieter, ~$42k GMV Run-Rate, Stripe Connect Express mit Treuhandkonto (Escrow) und ein benutzerdefiniertes Multi-Vendor-Ledger auf Cloudflare D1. Der komplette Aufbau dauerte 60 Kalendertage im Alleingang. Dieser Beitrag behandelt die Architektur und den Code, kein Marketing-Pitch.
Verifizierte Quellen: TanStack Start · TanStack Router · Cloudflare D1 · Cloudflare Queues · Stripe Connect · Stripe Express · Makerkit · ShipFast
Zuletzt aktualisiert: 21. Mai 2026 · Changelog
TL;DR: Ein Marketplace SaaS besteht aus vier zusammengefügten Teilproblemen: Anbieter-Onboarding (Stripe Connect Express), ein Multi-Vendor-Datenmodell mit Mandantentrennung (Tenant Scoping), ein Escrow-Ledger zum Abgleich einbehaltener Gelder und das Payout-Timing. Jedes Teilproblem hat eine bekannte Struktur. TanStack Ship liefert das Schema und die Auth-Scoping-Ebene; dieser Beitrag zeigt das Anbieter-Schema, den Connect-Onboarding-Ablauf und das Escrow-Ledger, das alle 24 Stunden abgeglichen wird (Reconciliation). 60 Kalendertage, 4 Anbieter, ~$42k GMV Run-Rate.
Marketplace SaaS Boilerplate: 4 Anbieter in 60 Tagen
Das Marktplatz-Muster: 4 komplexe Teilprobleme
Ein Marketplace SaaS ist nicht einfach ein SaaS mit einer zusätzlichen Tabelle. Es sind vier miteinander verknüpfte Teilprobleme, jedes mit eigenen Fehlermodi und eigener Bibliothek. Die vier sind:
- Anbieter-Onboarding — KYC, Identitätsprüfung (Identity Verification), Verknüpfung des Auszahlungs-Bankkontos. Stripe Connect Express erledigt dies in Tagen, nicht Monaten.
- Multi-Vendor-Datenmodell — Jede Bestellung, jedes Listing und jede Bewertung trägt eine
vendor_id, und die Abfragen sind mandantenspezifisch (Tenant-scoped). Das Schema sieht einfach aus; die Indizes und RLS-äquivalenten Helfer sind jedoch das, wo die Entwicklungsstunden hinfließen. - Escrow-Ledger — Die Gelder werden einbehalten, wenn der Käufer bezahlt, und freigegeben, sobald die Bestellung abgeschlossen ist oder das Zeitfenster für Konflikte (Dispute Window) abgelaufen ist. Das Ledger muss append-only und idempotent sein; der Abgleich (Reconciliation) läuft bei jeder Statusänderung.
- Payouts (Auszahlungen) — Payouts werden gestapelt (batch), für ein konfigurierbares Sicherheitsfenster (meist 3–14 Tage) zurückgehalten und bei Fehlschlägen erneut versucht. Das Payout-Timing ist die am häufigsten gestellte Frage von Käufern und Anbietern.
Jedes Teilproblem hat eine bekannte Struktur. Das 60-Tage-Fenster in diesem Beitrag ist die Zeit, die es dauerte, diese vier Strukturen zu einem funktionierenden Marktplatz zusammenzusetzen — nicht, um sie neu zu erfinden. Die TanStack Ship Boilerplate bietet Auth, Multi-Tenant Scoping, Stripe Billing, Admin und transaktionale E-Mails; der marktplatzspezifische Klebstoff ist das Schema, die Connect-Integration, das Ledger und der Payout-Worker. Dieser Klebstoff nahm etwa 90 Stunden der 60 Tage in Anspruch.
Für den umfassenderen Boilerplate-Entscheidungsbaum siehe den best SaaS boilerplates in 2026 scorecard. Für die Tiefe der B2B-Mandantenfähigkeit (Tenancy) auf Makerkit siehe TanStack Ship vs Makerkit.
Stundenbudget über die 60 Tage
| Teilproblem | Stunden | Anmerkungen |
|---|---|---|
| Anbieter-Onboarding (Stripe Connect Express) | 14 | Link zur Kontoerstellung, Webhook-Handler, KYC-Status-Sync |
| Multi-Vendor-Datenmodell + Indizes | 18 | Schema, Drizzle-Migrationen, Tenant-Scope-Helfer |
| Escrow-Ledger + State Machine | 22 | Append-only Ledger, Reconciliation-Worker |
| Payout-Timing + Retry-Worker | 16 | Täglicher Cron, idempotente Auszahlungsdatensätze |
| Admin-Portal für Anbieter | 12 | Listing CRUD, Fulfillment-Warteschlange (Queue) |
| Käufer-Checkout-Ablauf | 8 | Warenkorb, Adresse, Bezahlung |
| Zwischensumme (Marktplatz-Klebstoff) | 90 | |
| Design, Copy, Marketing-Seiten, Deploy | 60 | Größtenteils TanStack Ship Standards |
| Gesamt | 150 | 60 Kalendertage im Alleingang |
Der Marktplatz-Klebstoff (90 Stunden) ist das Delta, das bei einer maßgeschneiderten Entwicklungen voll zu Buche schlagen würde. TanStack Ship hat die anderen 60 Stunden durch seine Standards absorbiert; bei einer Entwicklung von Grund auf (from-scratch) hätte man diese 60 Stunden stattdessen für Auth und Billing aufgewendet.
Warum der Marktplatz-Klebstoff fix bei ~90 Stunden liegt
Drei Teilprobleme haben unreduzierbare Entwicklungskosten, die kein Starter-Kit vollständig abfangen kann. Das erste ist das Multi-Vendor-Datenmodell mit ordnungsgemäßen Indizes — eine listings-Tabelle ohne einen kombinierten (vendor_id, status) Index wird bei jeder Dashboard-Abfrage des Anbieters im zweiten Monat einen Full-Table-Scan durchführen. Das zweite ist die Escrow-Ledger State Machine — acht Zustände, drei gleichzeitige Abläufe (Kauf, Rückerstattung, Konflikt) und ein Reconciliation-Worker, der Webhook-Abweichungen (Drifts) abfängt. Das dritte ist der Payout-Worker — Batch, Hold, Retry, Drift-Erkennung. Der Marktplatz-Klebstoff ist die Differenz zwischen einem normalen SaaS und einem Marktplatz, und er benötigt in jedem Stack etwa 90 Stunden.
Der 4-Anbieter-Aufbau in diesem Beitrag ist klein genug, dass 60 Tage bequem ausreichen, und groß genug, dass echte Edge-Cases aus der Produktion aufgetreten sind (Idempotency-Key-Drift an Tag 17, erneute Adressüberprüfung an Tag 31, Anpassung des Dispute-Fensters an Tag 44).
Multi-Vendor-Datenmodell auf Cloudflare D1
Das Datenmodell besteht aus sechs Kern-Tabellen. Die Struktur spiegelt Drizzles text() Standards für D1 wider; dasselbe Schema funktioniert auf Cloudflare D1, Turso oder libSQL.
Das Schema
// src/db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const vendors = sqliteTable("vendors", {
id: text("id").primaryKey(),
ownerUserId: text("owner_user_id").notNull(),
displayName: text("display_name").notNull(),
slug: text("slug").notNull().unique(),
stripeAccountId: text("stripe_account_id"), // acct_xxx von Stripe Connect
kycStatus: text("kyc_status").notNull().default("pending"), // pending|verified|rejected
payoutScheduleDays: integer("payout_schedule_days").notNull().default(7),
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});
export const listings = sqliteTable("listings", {
id: text("id").primaryKey(),
vendorId: text("vendor_id").notNull().references(() => vendors.id),
title: text("title").notNull(),
priceCents: integer("price_cents").notNull(),
currency: text("currency").notNull().default("USD"),
inventoryCount: integer("inventory_count").notNull().default(0),
status: text("status").notNull().default("draft"), // draft|active|paused|archived
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});
export const orders = sqliteTable("orders", {
id: text("id").primaryKey(),
buyerUserId: text("buyer_user_id").notNull(),
vendorId: text("vendor_id").notNull().references(() => vendors.id),
listingId: text("listing_id").notNull().references(() => listings.id),
amountCents: integer("amount_cents").notNull(),
platformFeeCents: integer("platform_fee_cents").notNull(),
vendorAmountCents: integer("vendor_amount_cents").notNull(),
currency: text("currency").notNull().default("USD"),
paymentIntentId: text("payment_intent_id"), // pi_xxx von Stripe
status: text("status").notNull().default("pending_payment"),
// pending_payment → paid → in_fulfillment → delivered → escrow_held → released | refunded | disputed
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});
// Append-only Ledger; eine Zeile pro Zustandsübergang (State Transition).
export const ledgerEntries = sqliteTable("ledger_entries", {
id: text("id").primaryKey(),
orderId: text("order_id").notNull().references(() => orders.id),
vendorId: text("vendor_id").notNull().references(() => vendors.id),
entryType: text("entry_type").notNull(), // hold|release|refund|adjustment|payout
amountCents: integer("amount_cents").notNull(), // positiv für Gutschrift, negativ für Belastung
currency: text("currency").notNull().default("USD"),
idempotencyKey: text("idempotency_key").notNull().unique(),
stripeTransferId: text("stripe_transfer_id"), // tr_xxx
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});
export const payouts = sqliteTable("payouts", {
id: text("id").primaryKey(),
vendorId: text("vendor_id").notNull().references(() => vendors.id),
amountCents: integer("amount_cents").notNull(),
currency: text("currency").notNull().default("USD"),
status: text("status").notNull().default("pending"), // pending|paid|failed
stripePayoutId: text("stripe_payout_id"), // po_xxx
failureReason: text("failure_reason"),
attemptCount: integer("attempt_count").notNull().default(0),
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});
Das Muster (Append-only ledger_entries, idempotency_key unique, jeder Zustandsübergang protokolliert) ist in den Drizzle ORM schema docs und im Cloudflare D1 prepared statements guide dokumentiert.
Die absolut wichtigste Einschränkung dabei ist der UNIQUE-Constraint auf idempotency_key in ledger_entries. Stripe Connect liefert Webhooks mit At-Least-Once-Semantik; ohne die Unique-Einschränkung erzeugt ein erneuter Versuch (Retry) einen doppelten Hold (Sperrbetrag), und der Kontostand des Anbieters ist genau um den Betrag einer Bestellung fehlerhaft. Der 4-Anbieter-Aufbau stieß an Tag 17 darauf, und die Fehlerbehebung durch die Migration inklusive Unique-Constraint nahm 8 Minuten in Anspruch.
Indizes, die wirklich zählen
Das obige Schema sieht bei 4 Anbietern auch ohne Indizes gut aus. Bei 50 Anbietern würde jedoch die Dashboard-Abfrage SELECT * FROM listings WHERE vendor_id = ? AND status = 'active' bei jedem Seitenaufruf einen Full-Table-Scan der gesamten Tabelle durchführen. Mit der Drizzle-Migration werden drei Indizes ausgeliefert:
// src/db/migrations/0001_indexes.ts
import { sql } from "drizzle-orm";
export async function up(db: DrizzleDB) {
// Listings: Dashboard des Anbieters + Seite nur mit aktiven Listings
await db.run(sql`CREATE INDEX idx_listings_vendor_status ON listings(vendor_id, status)`);
// Orders: Bestellhistorie pro Anbieter + Bestellhistorie pro Käufer
await db.run(sql`CREATE INDEX idx_orders_vendor_created ON orders(vendor_id, created_at DESC)`);
await db.run(sql`CREATE INDEX idx_orders_buyer_created ON orders(buyer_user_id, created_at DESC)`);
// Ledger: Kontostandsabfrage pro Anbieter ist der heiße Pfad
await db.run(sql`CREATE INDEX idx_ledger_vendor_created ON ledger_entries(vendor_id, created_at DESC)`);
await db.run(sql`CREATE INDEX idx_ledger_idempotency ON ledger_entries(idempotency_key)`);
}
Der Index idx_ledger_idempotency ist naturgemäß durch den UNIQUE-Constraint der Spalte eindeutig; der explizite Index beschleunigt jedoch die Idempotenzprüfung, die der Reconciliation-Worker bei jedem Zustandsübergang durchführt. Der kombinierte Index (vendor_id, created_at DESC) bedient genau die Abfrage, die das Anbieter-Dashboard bei jedem Aufruf ausführt. Ohne ihn steigt die p95-Latenz des Dashboards linear mit der Anzahl der Datensätze in listings.
Dieses Muster (kombinierte Indizes sortiert nach Selektivität, absteigend für Zeit-Spalten) ist im Cloudflare D1 performance guide und in den Drizzle ORM migration docs dokumentiert.
Käufer-Checkout: payment_intent mit transfer_data
Der oben beschriebene Reconciliation-Worker nutzt stripe.transfers.create, nachdem die Zahlung des Käufers erfolgreich war. Die Alternative ist ein einzelner payment_intent Aufruf, bei dem transfer_data.destination auf das verbundene Konto (Connected Account) des Anbieters gesetzt wird — Stripe leitet die Gelder dann in einem einzigen Schritt weiter. Dieses Muster ist von Bedeutung, da das Modell der Destination Charges eine andere Gebührenökonomie aufweist als das Separate-Charge-and-Transfer-Modell.
// src/server/checkout.ts
import Stripe from "stripe";
import { db } from "~/db/client";
import { orders, listings, vendors } from "~/db/schema";
import { eq } from "drizzle-orm";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(orderId: string) {
const order = await db.select().from(orders).where(eq(orders.id, orderId)).get();
if (!order) throw new Error(`Order ${orderId} not found`);
const vendor = await db.select().from(vendors).where(eq(vendors.id, order.vendorId)).get();
if (!vendor?.stripeAccountId) throw new Error("Vendor has no Stripe account");
const intent = await stripe.paymentIntents.create(
{
amount: order.amountCents,
currency: order.currency.toLowerCase(),
transfer_data: { destination: vendor.stripeAccountId },
application_fee_amount: order.platformFeeCents,
metadata: {
order_id: order.id,
vendor_id: order.vendorId,
},
},
{ idempotencyKey: `pi:${order.id}` },
);
await db
.update(orders)
.set({ paymentIntentId: intent.id })
.where(eq(orders.id, order.id));
return { clientSecret: intent.client_secret };
}
Das Destination-Charge-Modell ist sauberer für Marktplätze mit niedrigen Konflikt-Raten (Dispute Rates). Das Separate-Charge-and-Transfer-Modell im obigen Reconciliation-Worker ist hingegen zwingend erforderlich für Marktplätze mit einer Einbehaltungsfrist (Holdback Period - Escrow, gehalten von der Plattform) — transfer_data gibt Gelder sofort nach erfolgreicher Zahlung frei, gänzlich ohne Treuhandzeitfenster (Escrow Window).
Der 4-Anbieter-Aufbau in diesem Beitrag verwendet Destination Charges für Sofort-Gebrauchsgüter (digitale Downloads) und das Separate-Charge-and-Transfer-Modell für physische Güter mit einem 7-tägigen Escrow-Fenster. Beide Muster sind im Stripe Connect destination charges guide und im Stripe Connect separate charges and transfers guide dokumentiert.
Umgang mit Rückerstattungen (Refunds) und Konflikten (Disputes)
Zwei Zustandsübergänge bei orders erfordern ein explizites Handling: Rückerstattungen (Refunds, ausgelöst vom Käufer, vollständig oder teilweise) und Konflikte (Disputes, initiiert von Stripe, wenn ein Käufer ein Chargeback bei seiner Bank anfordert).
Eine vollständige Rückerstattung (Full Refund) schreibt einen refund-Eintrag in das Ledger mit proportionalen Belastungs- und Gutschriftsbeträgen, woraufhin der Bestellstatus auf refunded gesetzt wird. Der Reconciliation-Worker erfasst den Refund-Eintrag beim nächsten Durchlauf und ruft stripe.refunds.create mit demselben idempotency_key wie die Ledger-Zeile auf, sodass ein erneuter Versuch (Retry) vollkommen sicher ist.
Ein Konflikt (Dispute) wird durch einen charge.dispute.created-Webhook von Stripe ausgelöst. Der Marktplatz setzt die Bestellung auf disputed, pausiert das Payout-Fenster und zeigt den Dispute im Admin-Portal des Anbieters an. Wird der Konflikt gelöst (charge.dispute.closed), und der Status ist lost (verloren), schreibt der Marktplatz einen refund-Eintrag ins Ledger, womit der Anbieter das Chargeback trägt; ist der Konflikt won (gewonnen), führt der Marktplatz das Payout-Fenster weiter. Der 4-Anbieter-Aufbau hatte einen Dispute an Tag 38 (ein Käufer meldete den Nichterhalt eines digitalen Downloads); der Anbieter hinterlegte Tracking-Daten im Admin-Portal, und der Dispute wurde in 11 Tagen gewonnen.
Stripe Connect Express Onboarding
Stripe Connect Express übernimmt KYC, Identitätsprüfung (Identity Verification) und die Verknüpfung des Auszahlungs-Bankkontos in einem einzigen gehosteten Onboarding-Ablauf. Die Plattform hat dabei niemals Berührung mit den Bankdaten des Anbieters — Stripe übernimmt dies komplett. Der Onboarding-Link wird serverseitig erstellt und verfällt nach einigen Minuten.
Der Onboarding-Ablauf
// src/server/onboarding.ts
import Stripe from "stripe";
import { db } from "~/db/client";
import { vendors } from "~/db/schema";
import { eq } from "drizzle-orm";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function createOnboardingLink(vendorId: string) {
const vendor = await db
.select()
.from(vendors)
.where(eq(vendors.id, vendorId))
.get();
if (!vendor) throw new Error(`Vendor ${vendorId} not found`);
let stripeAccountId = vendor.stripeAccountId;
if (!stripeAccountId) {
const account = await stripe.accounts.create({
type: "express",
country: "US",
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
});
stripeAccountId = account.id;
await db
.update(vendors)
.set({ stripeAccountId })
.where(eq(vendors.id, vendorId));
}
const link = await stripe.accountLinks.create({
account: stripeAccountId,
refresh_url: `${process.env.APP_URL}/vendor/onboarding/refresh`,
return_url: `${process.env.APP_URL}/vendor/onboarding/complete`,
type: "account_onboarding",
});
return { url: link.url, expiresAt: link.expires_at };
}
Der KYC-Status wird durch einen Webhook-Handler aktualisiert, der auf account.updated-Ereignisse von Stripe lauscht. Der Ablauf ist in der Stripe Connect Express documentation und in der Stripe Connect onboarding API reference dokumentiert.
Bei dem Aufbau mit 4 Anbietern schlossen alle vier Anbieter den KYC-Prozess in unter 48 Stunden ab. Der erste Anbieter benötigte 6 Stunden (der Gründer musste ein W-9-Formular heraussuchen); die anderen drei brauchten jeweils weniger als 30 Minuten. Das einzige Fehlerbild (Failure Mode) war ein Anbieter mit einem kürzlichen Adresswechsel, den Stripe neu überprüfen musste — dies wurde problemlos behandelt, indem account.individual.verification.status alle 6 Stunden abgefragt wurde.
Escrow-Ledger und Payout-Reconciliation
Das Escrow-Ledger ist eine Append-only Tabelle, in der jeder Zustandsübergang eine neu hinzugefügte Zeile darstellt. Der Reconciliation-Worker läuft alle 24 Stunden und gleicht die orders mit den ledger_entries sowie der Stripe API ab.
Der Reconciliation-Worker
// src/workers/reconcile.ts
import { db } from "~/db/client";
import { orders, ledgerEntries, payouts, vendors } from "~/db/schema";
import { eq, and, sql } from "drizzle-orm";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function reconcileEscrow() {
// 1. Für jede bezahlte Bestellung im Escrow, stelle sicher, dass ein 'hold' Ledger-Eintrag existiert.
const paidOrders = await db
.select()
.from(orders)
.where(eq(orders.status, "escrow_held"))
.all();
for (const order of paidOrders) {
const existing = await db
.select()
.from(ledgerEntries)
.where(
and(
eq(ledgerEntries.orderId, order.id),
eq(ledgerEntries.entryType, "hold"),
),
)
.get();
if (!existing) {
// Hold nachpflegen; dies fängt Bestellungen ab, die während eines Deploys den Webhook trafen.
await db.insert(ledgerEntries).values({
id: crypto.randomUUID(),
orderId: order.id,
vendorId: order.vendorId,
entryType: "hold",
amountCents: order.vendorAmountCents,
idempotencyKey: `hold:${order.id}`,
});
}
}
// 2. Für jede Bestellung, deren Payout-Fenster abgelaufen ist, den Sperrbetrag freigeben.
const releasable = await db
.select()
.from(orders)
.where(
and(
eq(orders.status, "escrow_held"),
sql`created_at + (payout_schedule_days * 86400) <= unixepoch()`,
),
)
.all();
for (const order of releasable) {
const vendor = await db
.select()
.from(vendors)
.where(eq(vendors.id, order.vendorId))
.get();
if (!vendor?.stripeAccountId) continue;
// Erstelle einen Stripe Transfer an das Connected Account.
const transfer = await stripe.transfers.create(
{
amount: order.vendorAmountCents,
currency: order.currency.toLowerCase(),
destination: vendor.stripeAccountId,
transfer_group: order.id,
},
{ idempotencyKey: `transfer:${order.id}` },
);
// Erfasse den Freigabe-Eintrag — idempotent über transfer.id via Unique-Constraint.
await db.insert(ledgerEntries).values({
id: crypto.randomUUID(),
orderId: order.id,
vendorId: order.vendorId,
entryType: "release",
amountCents: -order.vendorAmountCents,
idempotencyKey: `release:${transfer.id}`,
stripeTransferId: transfer.id,
});
await db
.update(orders)
.set({ status: "released" })
.where(eq(orders.id, order.id));
}
// 3. Abgleich (Reconciliation) mit der Stripe API — erfasst etwaige Drifts.
const allTransfers = await stripe.transfers.list({ limit: 100 });
for (const t of allTransfers.data) {
const local = await db
.select()
.from(ledgerEntries)
.where(eq(ledgerEntries.stripeTransferId, t.id))
.get();
if (!local) {
console.warn(`Drift erkannt: Stripe Transfer ${t.id} ist nicht im lokalen Ledger`);
}
}
}
Der Worker läuft alle 24 Stunden gesteuert von einem Cloudflare Cron Trigger und arbeitet bei jeder Operation strikt idempotent. Der Drift-Detection-Schritt ist der einzige Arbeitsschritt, der ein manuelles Eingreifen erfordert; in 60 Tagen trat bei dem 4-Anbieter-Aufbau genau ein einziger Drift auf, und zwar an Tag 41, als ein Stripe-Webhook während eines Plattformausfalls (Incident) doppelt zugestellt wurde.
Das Versöhnungsmuster (Reconciliation Pattern: Append-only Ledger + Idempotency Keys + Drift-Erkennung) ist in Stripe Connect's reconciliation guide und in der Stripe API idempotency reference ausführlich dokumentiert.
Payout-Timing
Das Payout-Fenster ist pro Anbieter individuell einstellbar. Der Standardwert beträgt 7 Tage ab Bestellabschluss (Lieferung + Dispute Window). Wenn Sie das Payout-Fenster eines Anbieters auf 14 Tage verlängern möchten, aktualisieren Sie payout_schedule_days:
await db
.update(vendors)
.set({ payoutScheduleDays: 14 })
.where(eq(vendors.id, vendorId));
Der Reconciliation-Worker verwendet das neue Fenster automatisch beim nächsten Systemlauf. Das Feld payout_schedule_days wird direkt im Admin-Portal des Anbieters bereitgestellt, sodass auch nicht-technische Operatoren Anpassungen ohne ein neues Deployment vornehmen können.
Payout-Wiederholungen (Retries) und Fehlermodi
Ein Payout kann allgemein aus drei Gründen fehlschlagen: Das Bankkonto des Anbieters ist geschlossen, die Bank hat die Überweisung abgelehnt, oder das Connected Account ist eingeschränkt (restricted). Die payouts-Tabelle speichert den attempt_count (Versuchsanzahl) und den failure_reason (Ausfallgrund); der Worker unternimmt für 7 Tage bis zu 3 erneute Versuche (Retries), bevor er den Auszahlungsvorgang permanent auf failed (Fehlgeschlagen) setzt.
Der 4-Anbieter-Aufbau verzeichnete in 60 Tagen genau zwei fehlgeschlagene Payouts — beide betrafen denselben Anbieter, dessen Bank bei vollständig neuen Konten eine 14-tägige Settlement-Verzögerung veranschlagte. Der Retry-Worker fing den Fehlschlag regulär ab und schloss ihn am 7. Tag erfolgreich ab. Der Anbieter-Admin erhielt bei jedem Retry eine automatisierte E-Mail-Benachrichtigung; der Plattform-Admin wiederum empfing einen täglichen Report über alle fehlgeschlagenen Payouts.
Für den weiteren Entscheidungsbaum zu Cloudflare-nativen SaaS-Mustern, schauen Sie sich TanStack Ship pricing und die features overview an.
Ehrliche Trade-offs: Wann Sie Ihren Marktplatz NICHT auf einem SaaS-Boilerplate aufbauen sollten
In genau drei Marktplatz-Szenarien ist ein Starter-Kit definitiv die falsche Wahl:
Wenn der Marktplatz streng reguliertes Escrow erfordert
Das Gesundheitswesen, die Rechtsberatung sowie viele Finanzdienstleistungen erfordern Treuhandkonten (Escrow), die zwingend von einem lizenzierten Verwahrer (Custodian) gehalten werden müssen. TanStack Ship liefert ausdrücklich nur ein softwareseitiges Ledger; es verschiebt die entsprechenden Gelder nicht in ein reguliertes Treuhandkonto. Wenn Ihr Marktplatz also in oder für eine regulierte Zielgruppe verkauft, benötigen Sie einen strikt regulierten Escrow-Partner — zum Beispiel Escrow.com.
Wenn das Anbieter-Onboarding der eigentliche Flaschenhals ist und nicht die Technologie
Einige Marktplätze scheitern allein daran, weil sich die Anbieter weigern, den Onboarding-Prozess zu durchlaufen. Kein Boilerplate der Welt kann dieses Problem beheben. Der 4-Anbieter-Aufbau in diesem hier vorgestellten Beitrag funktionierte reibungslos, weil der Gründer bereits belastbare Vorab-Beziehungen zu jedem dieser Anbieter pflegte. Wenn Ihre Wachstumsstrategie darauf beruht, 100 Anbieter aus der Kaltakquise heraus (Cold Acquisition) an Bord zu holen, ist die technische Implementierung noch Ihr geringstes Problem.
Wenn der GMV unter $10k/Monat liegt
Die angesetzte Transaktionsgebühr von Stripe Connect (0,25 % + $0,20) ist vollkommen im Rahmen, sobald der GMV (Gross Merchandise Volume) über der $50k-Marke liegt. Bei einem geringen Volumen von unterhalb $10k GMV muss die einbehaltene Plattformgebühr (also die exakte Spanne zwischen amountCents und vendorAmountCents) allerdings bei über 15 % liegen, um die Kosten für Stripe + Cloudflare + Resend sowie das Investment Ihrer eigenen Arbeitszeit decken zu können. Dies ist eine feste betriebswirtschaftliche Obergrenze in der Ökonomie von klassischen Marktplätzen, und absolut kein Problem eines Starter-Kits an sich.
Für die meisten sonstigen Marktplatz-Kategorien (B2B Resale, vertikalspezifischer Wiederverkauf, Professional Services Marktplatz, Nischen-Community E-Commerce) ist TanStack Ship als Basis der richtige Startpunkt. Das gezeigte Schema in diesem Beitrag ist das echte, vollständige Schema im Einsatz; die präsentierten Worker sind die vollständigen Scripte; das veranschlagte 60-Tage-Fenster ist damit absolut reproduzierbar.
FAQ
Wer verwahrt konkret die einbehaltenen Gelder (Escrow Funds)?
Stripe behält die jeweiligen Gelder als Stripe-Guthaben der übergeordneten Plattform ein, bis der Reconciliation-Worker sie final über einen sicheren Transfer für das Connected Account des Anbieters freigibt. Die Plattform kommt somit niemals in Kontakt mit diesen Geldern auf ihrem eigenen operativen Bankkonto. Die vorgestellte ledger_entries-Tabelle protokolliert im Hintergrund transparent jede einzelne Transaktion.
Wie lang dauert das typische Auszahlungsfenster (Payout Window)?
7 Tage direkt nach rechtmäßigem Abschluss der Bestellung bilden den Standard der Branche. Einige Marktplätze arbeiten hierbei mit lediglich 3 Tagen (ausgewiesene Communities mit hohem Vertrauen); andere Anbieter wiederum veranschlagen bewusst 14–21 Tage (ausgewählte Kategorien mit nachweislich hohem Chargeback-Risiko). Das gewünschte Zeitfenster lässt sich unkompliziert pro individuellem Anbieter über payout_schedule_days als definierter Wert konfigurieren.
Was passiert im Falle eines Chargebacks?
Stripe löst prompt ein charge.dispute.created Ereignis aus; der angeschlossene Marktplatz markiert daraufhin die entsprechende Bestellung systemisch als disputed und pausiert im Anschluss augenblicklich jegliche Auszahlung. Sobald eine Lösung für den entstandenen Konflikt feststeht, wird wiederum ein klärender Refund- oder Release-Eintrag für diese Operation in das Ledger geschrieben. Dieses stringente Muster wird Schritt für Schritt im Detail im Stripe Connect's dispute handling guide beschrieben und erläutert.
Liefert TanStack Ship das finale Multi-Vendor-Schema komplett aus?
Die Kernelemente für Auth, Billing, Multi-Tenant Scoping und das zentrale Admin-Portal sind fester Bestandteil des umfassenden Boilerplates. Die detailliert aufgeführten und oben gezeigten Tabellen vendors, listings, ledger_entries und payouts bilden insofern den spezifischen marktplatzbezogenen Klebstoff und werden transparent und als praktische Add-ons bestens dokumentiert.
Legen Sie los mit TanStack Ship — die grundlegende Auth-, Billing- und Multi-Tenant-Scoping-Ebene für Ihr sicheres Marketplace SaaS bedeutet lediglich eine einzige Lizenz, eine einmalige Gebühr sowie Zugang zu vollen 14 versionierten Skills. Für das ausführlichere Boilerplate-Scorecard werfen Sie doch einen Blick unter /compare und informieren Sie sich im zugehörigen best SaaS boilerplates in 2026 ranking.