A Stripe webhook event is dispatched again when its final status update fails #267

Open
opened 2026-10-24 06:52:47 +00:00 by cgalo5758 · 0 comments
Owner

What happens

ProcessWebhookEvent (internal/integrations/stripe/workflows/webhook.go) handles one Stripe event in three steps that do not share a transaction: an update that marks the row processing (only from received, processing or failed), the dispatch to the handler for the event's type, and an update that marks the row completed or skipped. When the dispatch succeeds and that last update fails (a dropped connection, a database restart), or the worker dies between the two, the attempt fails and Temporal retries the activity. The row is still processing, the first update accepts it, and the handler runs a second time.

The impact is low. Each handler tolerates a second run of the same event, and no page shows a difference:

  • checkout.session.completed and customer.subscription.* call fulfillment.ReconcileSubscription, which reads the subscription from Stripe again and converges the console's rows to it. It records a subscription change only when the status differs, and it confers, ends or resumes nothing the first run already did.
  • invoice.finalized, invoice.paid, invoice.payment_failed and invoice.voided call ReconcileInvoice, which reads the invoice back and inserts only the lines and charges the console does not hold yet; the invoice sweep runs the same reconcile on a schedule.
  • customer.*, product.* and price.* write the same values to the same mapping row, which moves its updated_at, and skip the event when a newer event for the same object has completed.
  • payment_method.detached sets payment_methods.detached_at again, to the time of the second run; no query reads that column.
  • payment_method.attached and payment_method.updated write the card's brand, last four digits, expiry and funding from the event's payload again, and link the payments that name no method.

The second run costs one more round of Stripe API reads for the subscription and invoice events. One case can leave stored details older than Stripe's: the payment-method handlers do not check for a newer event about the same card, so when a newer payment_method.updated for that card completes between the two runs, the second run writes the older event's card details over the newer ones until the card's next event.

What should happen

A retry of an attempt whose dispatch already finished does not run the handler again: for example, the final status update is retried on its own before the attempt reports failure, or the row records that the dispatch finished so the retry only completes the row. The payment-method handlers also skip an event that a newer completed event for the same payment method supersedes, as the customer, product and price handlers do.

Where

internal/integrations/stripe/workflows/webhook.go (ProcessWebhookEvent)

Steps

  1. In a test, run ProcessWebhookEvent for a price.updated event on a database handle whose statement after the dispatch fails.
  2. Run the activity again, as Temporal's retry does: the row is still processing, and the handler writes the price mapping a second time.

Why it matters

Every handler happens to tolerate a second run today, so the cost is extra Stripe reads and touched rows, but a handler that is not idempotent would act twice with nothing in the activity to stop it.

### What happens `ProcessWebhookEvent` (`internal/integrations/stripe/workflows/webhook.go`) handles one Stripe event in three steps that do not share a transaction: an update that marks the row `processing` (only from `received`, `processing` or `failed`), the dispatch to the handler for the event's type, and an update that marks the row `completed` or `skipped`. When the dispatch succeeds and that last update fails (a dropped connection, a database restart), or the worker dies between the two, the attempt fails and Temporal retries the activity. The row is still `processing`, the first update accepts it, and the handler runs a second time. The impact is low. Each handler tolerates a second run of the same event, and no page shows a difference: - `checkout.session.completed` and `customer.subscription.*` call `fulfillment.ReconcileSubscription`, which reads the subscription from Stripe again and converges the console's rows to it. It records a subscription change only when the status differs, and it confers, ends or resumes nothing the first run already did. - `invoice.finalized`, `invoice.paid`, `invoice.payment_failed` and `invoice.voided` call `ReconcileInvoice`, which reads the invoice back and inserts only the lines and charges the console does not hold yet; the invoice sweep runs the same reconcile on a schedule. - `customer.*`, `product.*` and `price.*` write the same values to the same mapping row, which moves its `updated_at`, and skip the event when a newer event for the same object has completed. - `payment_method.detached` sets `payment_methods.detached_at` again, to the time of the second run; no query reads that column. - `payment_method.attached` and `payment_method.updated` write the card's brand, last four digits, expiry and funding from the event's payload again, and link the payments that name no method. The second run costs one more round of Stripe API reads for the subscription and invoice events. One case can leave stored details older than Stripe's: the payment-method handlers do not check for a newer event about the same card, so when a newer `payment_method.updated` for that card completes between the two runs, the second run writes the older event's card details over the newer ones until the card's next event. ### What should happen A retry of an attempt whose dispatch already finished does not run the handler again: for example, the final status update is retried on its own before the attempt reports failure, or the row records that the dispatch finished so the retry only completes the row. The payment-method handlers also skip an event that a newer completed event for the same payment method supersedes, as the customer, product and price handlers do. ### Where `internal/integrations/stripe/workflows/webhook.go` (`ProcessWebhookEvent`) ### Steps 1. In a test, run `ProcessWebhookEvent` for a `price.updated` event on a database handle whose statement after the dispatch fails. 2. Run the activity again, as Temporal's retry does: the row is still `processing`, and the handler writes the price mapping a second time. ### Why it matters Every handler happens to tolerate a second run today, so the cost is extra Stripe reads and touched rows, but a handler that is not idempotent would act twice with nothing in the activity to stop it.
cgalo5758 added the
kind
bug
area/billingarea/integrations
priority
low
labels 2026-10-24 06:52:47 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#267