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.

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.
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.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.
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.

Run in your terminal to get started locally.
-- 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.
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.
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.
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.
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.