Nov 10, 20269 min read

Marketplace Payouts Backend with Stripe Connect

Build the backend that records seller earnings, holds funds through the refund window, batches daily payouts and sends Stripe Connect transfers exactly once, with the hand-built AWS path and the same design in Go with Encore.

Illustration for Marketplace Payouts Backend with Stripe Connect

A marketplace collects money from buyers and owes part of it to sellers, and the payouts backend is the code that decides how much, when, and makes sure the transfer happens exactly once. Mistakes are expensive in both directions: a retried job that transfers twice is cash the platform has to recover from a seller, a refund that arrives after the seller was paid comes out of the platform's balance, and a batch that dies halfway leaves nobody sure which sellers were paid.

This guide designs a payouts backend for that job: a ledger of seller earnings, a hold period for refunds, a daily payout batch and Stripe Connect transfers with idempotency keys, first by hand on AWS and then in Go with Encore.

The payouts backend at a glance

JobHand-built on AWSWith Encore in your AWS account
Seller earnings ledgerRDS for PostgreSQLEncore database on RDS
Daily payout batchEventBridge Scheduler and a taskCron job
One transfer per payoutSQS queue and a workerPub/Sub topic and subscription (SNS and SQS)
Stripe API keySecrets Manager plus IAM and SDK codeEncore secret, stored in Secrets Manager
Retries without double paymentIdempotency keys in your workerSame, with retry policy declared in code
Local developmentStripe test mode plus a dev accountencore run with Stripe test mode

How do you build it by hand on AWS?

  1. Ledger: An RDS for PostgreSQL table holds one row per earning or adjustment per seller: the sale amount minus the platform fee, refunds as negative rows, and an available_at timestamp set to the end of the refund window. Amounts are integer cents, and a unique key on order and entry type makes recording a sale idempotent.
  2. Batch: An EventBridge Scheduler schedule runs a task once a day. For each seller with a positive available balance, it creates a payout row and assigns the seller's open entries to it in one transaction, so the next batch cannot pick them up again.
  3. Transfers: The batch sends one SQS message per payout, and a worker creates a Stripe Transfer to the seller's connected account with the payout ID as the idempotency key. A dead-letter queue catches payouts that keep failing, such as a connected account with payouts disabled.
  4. Secrets and access: The Stripe secret key lives in Secrets Manager, and the worker's IAM role is the only one allowed to read it.

The worker, the scheduled task and the API each need their own IAM role, security groups, environment variables for the queue URL and secret ARN, and the same set again for a staging account.

How do you build it with Encore?

Encore

Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. The ledger database, the daily batch schedule, the payout topic and the Stripe key are declared in the Go service that uses them, and deploying it creates RDS, SNS and SQS with a dead-letter queue and a Secrets Manager entry, with an IAM role for the service, as the AWS infrastructure docs list. The Stripe key is set with encore secret set and is never stored in the repository, so coding agents working on the code cannot read or leak it.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create market --example=hello-world$ cd market && mkdir -p payouts/migrations$ go get github.com/stripe/stripe-go/v87$ encore secret set --type dev,local StripeSecretKey
-- payouts/migrations/1_create_ledger.up.sql CREATE TABLE sellers ( id TEXT PRIMARY KEY, stripe_account_id TEXT NOT NULL ); CREATE TABLE payouts ( id BIGSERIAL PRIMARY KEY, seller_id TEXT NOT NULL REFERENCES sellers(id), amount_cents BIGINT NOT NULL CHECK (amount_cents > 0), status TEXT NOT NULL DEFAULT 'pending', stripe_transfer_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE seller_entries ( id BIGSERIAL PRIMARY KEY, seller_id TEXT NOT NULL REFERENCES sellers(id), order_id TEXT NOT NULL, kind TEXT NOT NULL, amount_cents BIGINT NOT NULL, available_at TIMESTAMPTZ NOT NULL, payout_id BIGINT REFERENCES payouts(id), UNIQUE (order_id, kind) );

The order service calls RecordSale when a charge succeeds, and the daily batch claims each seller's available entries into a payout and publishes it. claimEntries locks those rows with FOR UPDATE and refuses to commit a payout that did not actually attach any entries, so two overlapping runs cannot transfer the same balance twice:

// payouts/ledger.go package payouts import ( "context" "time" "encore.dev/cron" "encore.dev/pubsub" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("payouts", sqldb.DatabaseConfig{Migrations: "./migrations"}) type PayoutCreated struct { PayoutID int64 `json:"payout_id"` } var Payouts = pubsub.NewTopic[*PayoutCreated]("payout-created", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type Sale struct { SellerID string `json:"seller_id"` OrderID string `json:"order_id"` AmountCents int64 `json:"amount_cents"` PlatformFeeBps int64 `json:"platform_fee_bps"` } // RecordSale credits the seller, held for a seven-day refund window. // //encore:api private func RecordSale(ctx context.Context, s *Sale) error { net := s.AmountCents - s.AmountCents*s.PlatformFeeBps/10000 _, err := db.Exec(ctx, ` INSERT INTO seller_entries (seller_id, order_id, kind, amount_cents, available_at) VALUES ($1, $2, 'sale', $3, $4) ON CONFLICT (order_id, kind) DO NOTHING `, s.SellerID, s.OrderID, net, time.Now().Add(7*24*time.Hour)) return err } var _ = cron.NewJob("daily-payouts", cron.JobConfig{ Title: "Batch available seller balances into payouts", Schedule: "0 6 * * *", Endpoint: CreatePayouts, }) //encore:api private func CreatePayouts(ctx context.Context) error { rows, err := db.Query(ctx, ` SELECT seller_id FROM seller_entries WHERE payout_id IS NULL AND available_at <= now() GROUP BY seller_id HAVING sum(amount_cents) > 0 `) if err != nil { return err } var sellers []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return err } sellers = append(sellers, id) } rows.Close() for _, sellerID := range sellers { payoutID, err := claimEntries(ctx, sellerID) if err != nil { return err } if payoutID == 0 { continue } if _, err := Payouts.Publish(ctx, &PayoutCreated{PayoutID: payoutID}); err != nil { return err } } return nil } // claimEntries moves a seller's open entries into one payout atomically. func claimEntries(ctx context.Context, sellerID string) (int64, error) { tx, err := db.Begin(ctx) if err != nil { return 0, err } defer tx.Rollback() // Lock the unclaimed rows so an overlapping run cannot sum the same balance. rows, err := tx.Query(ctx, ` SELECT amount_cents FROM seller_entries WHERE seller_id = $1 AND payout_id IS NULL AND available_at <= now() FOR UPDATE `, sellerID) if err != nil { return 0, err } var total int64 for rows.Next() { var amount int64 if err := rows.Scan(&amount); err != nil { rows.Close() return 0, err } total += amount } rows.Close() if err := rows.Err(); err != nil { return 0, err } if total <= 0 { return 0, nil } var payoutID int64 if err := tx.QueryRow(ctx, ` INSERT INTO payouts (seller_id, amount_cents) VALUES ($1, $2) RETURNING id `, sellerID, total).Scan(&payoutID); err != nil { return 0, err } result, err := tx.Exec(ctx, ` UPDATE seller_entries SET payout_id = $1 WHERE seller_id = $2 AND payout_id IS NULL AND available_at <= now() `, payoutID, sellerID) if err != nil { return 0, err } updated, err := result.RowsAffected() if err != nil { return 0, err } if updated == 0 { return 0, nil } return payoutID, tx.Commit() }

A subscription sends each payout to Stripe with the payout ID as the idempotency key, so a redelivered message or a retry after a timeout cannot transfer twice:

// payouts/transfer.go package payouts import ( "context" "strconv" "time" "encore.dev/pubsub" "github.com/stripe/stripe-go/v87" ) var secrets struct { StripeSecretKey string } var _ = pubsub.NewSubscription(Payouts, "send-transfer", pubsub.SubscriptionConfig[*PayoutCreated]{ Handler: sendTransfer, MaxConcurrency: 4, RetryPolicy: &pubsub.RetryPolicy{MinBackoff: 30 * time.Second, MaxRetries: 10}, }) func sendTransfer(ctx context.Context, msg *PayoutCreated) error { var amount int64 var status, account string err := db.QueryRow(ctx, ` SELECT p.amount_cents, p.status, s.stripe_account_id FROM payouts p JOIN sellers s ON s.id = p.seller_id WHERE p.id = $1 `, msg.PayoutID).Scan(&amount, &status, &account) if err != nil { return err } if status == "paid" { return nil } sc := stripe.NewClient(secrets.StripeSecretKey) params := &stripe.TransferCreateParams{ Amount: stripe.Int64(amount), Currency: stripe.String("usd"), Destination: stripe.String(account), } params.SetIdempotencyKey("payout-" + strconv.FormatInt(msg.PayoutID, 10)) tr, err := sc.V1Transfers.Create(ctx, params) if err != nil { return err } _, err = db.Exec(ctx, ` UPDATE payouts SET status = 'paid', stripe_transfer_id = $1 WHERE id = $2 `, tr.ID, msg.PayoutID) return err }

Keeping the batch and the transfer as separate steps limits the damage of a partial failure. If Stripe is slow or one connected account is restricted, only that payout retries with backoff, and after the last retry it moves to the subscription's dead-letter queue described in the Pub/Sub docs while the other sellers are paid. A batch that crashes halfway leaves every claimed payout in the database, and the publish can be repeated for any pending payout without double payment. Stripe keeps idempotency keys for at least 24 hours, so a payout stuck longer than that should be checked against the Transfers API before it is retried. Refunds become negative refund entries in seller_entries, which the next batch nets against new sales.

Cron schedules are in UTC, the job does not run locally or in preview environments, and the cron docs cover the schedule syntax. encore run starts Postgres and the topic locally, so with a Stripe test key and a test connected account the whole flow can be exercised by calling CreatePayouts from the development dashboard. The secret is defined per environment through Encore secrets, so the test key never reaches production. A coding agent asked to add weekly payouts for small sellers changes the batch query and the schedule in one diff, and the idempotency logic stays untouched.

Best fit: Marketplace and platform teams paying sellers through Stripe Connect from their own AWS account who want the ledger, the batch and the transfers in one Go service with the database, queues and secrets provisioned from it.

Things to decide early

  • Charge model. This guide uses separate charges and transfers. Destination charges move the seller's share at charge time and suit simpler flows without a hold period.
  • Currency. Store a currency on each entry if sellers are paid in more than one, and batch per seller and currency.
  • Negative balances. Decide whether a seller with more refunds than sales blocks payouts until it recovers or whether the platform absorbs it, and encode that in the batch query.

Frequently asked questions

How do marketplace payouts work with Stripe Connect?

With separate charges and transfers, the platform charges the buyer on its own Stripe account, records what each seller earned minus the platform fee, and later creates a Transfer to each seller's connected account. Stripe then pays the connected account's balance out to the seller's bank on that account's payout schedule.

How do I avoid paying a seller twice?

Create a payout row in your database before calling Stripe, assign the seller's ledger entries to it in the same transaction, and pass the payout ID as the Stripe idempotency key. A retried transfer request returns the original result instead of moving money again, and entries already assigned to a payout cannot be included in the next batch.

How do I build a payouts backend in Go with Encore?

Declare the ledger database with sqldb.NewDatabase, a daily batch with cron.NewJob, a Pub/Sub topic with pubsub.NewTopic that a subscription consumes to call Stripe one payout at a time, and the Stripe key as an Encore secret. Encore provisions RDS, SNS and SQS and Secrets Manager entries for these in your own AWS account.

Why hold seller funds before paying out?

Buyers can request refunds and file disputes after a sale. Holding each sale's earnings for a refund window, such as seven days, means a refund can be taken from the seller's unpaid balance instead of recovered from money that has already left the platform.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders