Scheduled Stripe Connect payouts: the timezone bug that moves money

Hourly marketplace payouts sound simple until daylight-saving time shifts your cutoff by an hour. Scheduling payouts with IANA zones and making retries safe.

If your marketplace pays sellers or professionals on a schedule, there’s a bug waiting for you twice a year. It won’t throw an error. It will quietly pay people an hour early or an hour late, and in the worst case it will pay them twice.

We hit both problems while building payout automation for a beauty-booking marketplace in Paris. Here’s what went wrong in the first design, and the version that has run cleanly since.

The naive version

The first draft of a scheduled payout job usually looks like this:

# Runs every hour via EventBridge
CUTOFF_OFFSET_HOURS = 2  # Paris is UTC+2, right?

def handler(event, context):
    now_utc = datetime.utcnow()
    local_now = now_utc + timedelta(hours=CUTOFF_OFFSET_HOURS)
    eligible = appointments_completed_before(local_now - timedelta(hours=24))
    for appt in eligible:
        stripe.Transfer.create(
            amount=appt.payout_cents,
            currency="eur",
            destination=appt.professional.stripe_account_id,
        )
        appt.mark_paid()

Three things are wrong with it, and they’re all common.

Problem 1: Paris is not UTC+2

Paris is UTC+2 in summer and UTC+1 in winter. A hard-coded offset is correct for half the year. When clocks change, every “24 hours after completion” calculation shifts by an hour. Professionals who expected money at 10:00 get it at 11:00, or the job pays out appointments that haven’t cleared the cancellation window yet.

The fix is to never do arithmetic with offsets. Use the IANA zone and let the standard library handle transitions:

from zoneinfo import ZoneInfo
from datetime import datetime, timedelta

PARIS = ZoneInfo("Europe/Paris")

def local_now() -> datetime:
    return datetime.now(tz=PARIS)

def payout_cutoff() -> datetime:
    return local_now() - timedelta(hours=24)

zoneinfo ships with Python 3.9+, and on Lambda you get the tz database for free. Store every timestamp in UTC in the database, convert at the edges, and compare timezone-aware datetimes only. If you ever see a naive datetime.utcnow() next to a business rule like “24 hours after”, that’s the bug.

Problem 2: a retry pays twice

Lambda retries on failure. EventBridge can deliver an event more than once. A deploy can overlap with a running invocation. In every one of those cases, the naive job will call stripe.Transfer.create a second time for the same appointment, because mark_paid() runs after the transfer.

Stripe solves this with idempotency keys, but only if you use them deterministically:

def payout_for(appt):
    key = f"payout:appointment:{appt.id}:v1"
    transfer = stripe.Transfer.create(
        amount=appt.payout_cents,
        currency="eur",
        destination=appt.professional.stripe_account_id,
        transfer_group=f"appointment_{appt.id}",
        idempotency_key=key,
    )
    return transfer

The key must be derived from the business entity, not from the run. If you generate a UUID per invocation, a retry gets a new key and Stripe happily creates a second transfer. With a stable key, Stripe returns the original transfer for 24 hours, and your code can treat “already exists” as success.

We go one step further and record the attempt before calling Stripe:

with db.transaction():
    payout = Payout.create_pending(appointment=appt, idempotency_key=key)

transfer = stripe.Transfer.create(..., idempotency_key=key)

with db.transaction():
    payout.mark_sent(transfer_id=transfer.id)

Now the database tells you exactly which appointments have a pending payout. A job that crashes between the two steps leaves a pending row, and the next run resumes it instead of starting over. Nothing is paid twice, and nothing is forgotten.

Problem 3: commission changes after booking

The amount you transfer is the appointment price minus your commission. If commission rates live in a settings table and the job reads them at payout time, changing the rate changes what people get paid for bookings that were already agreed.

Lock the commission when the booking is confirmed:

class Appointment:
    price_cents: int
    commission_rate: Decimal  # copied from the plan at booking time
    payment_mode: str

    @property
    def payout_cents(self) -> int:
        return int(self.price_cents * (1 - self.commission_rate))

This also protects you from a subtler issue: payment mode. A deposit-then-balance booking and a pay-in-full booking might carry different commission rules. If the mode changes after booking (a client pays the balance in cash, say), the payout should still honour the original agreement. Copy the rate onto the appointment and the question never comes up.

What the final job looks like

  • Runs hourly from EventBridge, in a Lambda with a 5-minute timeout and reserved concurrency of 1, so two runs never overlap.
  • Computes cutoffs in Europe/Paris with zoneinfo, never with offsets.
  • Selects appointments that are completed, past the dispute window, and have no sent payout.
  • Creates a pending payout row, then calls Stripe with an idempotency key derived from the appointment.
  • Marks the payout sent with the transfer ID, or failed with the Stripe error, and moves on.
  • A second job retries failed payouts with backoff and the same key.

That’s about 120 lines of Python. The important part isn’t the code, it’s the three decisions: real timezones, deterministic idempotency, and locking money-affecting rules at the moment they’re agreed.

If you’re building a marketplace with scheduled payouts and want this done right the first time, we can help.

Where we've done this

MarketplaceFrance

A beauty booking marketplace rebuilt as a serverless platform: bookings, deposits, payouts and a 66-story roadmap

AWS CDK backend for clients and beauty professionals: an 8-state appointment lifecycle, deposits and auto-confirm flows, hourly Stripe Connect payouts in Paris time, reminders over push, SMS and WhatsApp, and a data model built so an AI assistant can drive it safely.

  • 8appointment states, enforced server-side
  • 5roles with server-derived permissions
  • AWS CDK
  • Lambda
  • API Gateway
  • EventBridge

More from the blog

Need this built, not just explained?

Tell us what you're building. You'll get a straight answer on scope, timeline and cost, usually within one business day.