Skip to main content

max / makenotwork

Fix v2 webhook retry on account update failure Previously, failed v2 account update events were logged and silently acknowledged (200). The event was already marked processed, so Stripe would not retry. Now on failure the handler unmarks the event from the processed set and returns 500, allowing Stripe's built-in retry mechanism to re-deliver (retries for up to 3 days). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-09 14:44 UTC
Commit: fcbe59362513f152e1be31311f7f3cbb05493d12
Parent: 3adf0eb
3 files changed, +30 insertions, -23 deletions
@@ -63,7 +63,7 @@ Two-pass fuzz: initial scan + deep verification. Items marked REFUTED were dispr
63 63 - [x] MINOR: No Stripe minimum amount ($0.50) enforcement before creating session — added `STRIPE_MINIMUM_CHARGE_CENTS` check to all 3 checkout methods (`payments/checkout.rs`, `constants.rs`)
64 64 - [ ] MINOR: Cart items removed before Stripe session completes — cancel = empty cart, intentional UX trade-off (`routes/stripe/checkout/cart.rs:356,673`)
65 65 - [x] MINOR: Project checkout uses `ItemId::nil()` — made item_id optional in CreateTransactionParams, CheckoutParams, and CheckoutMetadata; project purchases now use NULL; fixed unique indexes to exclude NULL item_id (migration 104)
66 - - [ ] MINOR: v2 webhook handler swallows account update failures — needs v2 retry queue infrastructure (`routes/stripe/webhook_v2.rs:95-106`)
66 + - [x] MINOR: v2 webhook handler swallows account update failures — on failure, unmarks event from processed set and returns 500 so Stripe retries (`routes/stripe/webhook_v2.rs`, `db/webhook_events.rs`)
67 67 - ~~SERIOUS: Partial Stripe refunds revoke full access~~ REFUTED — `is_full_refund()` check at `billing.rs:272` prevents this
68 68 - ~~MINOR: $0 items dropped in `process_seller_checkout`~~ REFUTED — unreachable; `process_seller_checkout` is only called with `promo_code: None`
69 69
@@ -35,6 +35,16 @@ pub async fn try_mark_event_processed(pool: &PgPool, event_id: &str) -> Result<b
35 35 Ok(result.rows_affected() > 0)
36 36 }
37 37
38 + /// Remove a webhook event from the processed set, allowing Stripe to retry delivery.
39 + #[tracing::instrument(skip_all)]
40 + pub async fn unmark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> {
41 + sqlx::query("DELETE FROM processed_webhook_events WHERE event_id = $1")
42 + .bind(event_id)
43 + .execute(pool)
44 + .await?;
45 + Ok(())
46 + }
47 +
38 48 /// Insert a failed webhook event for later retry.
39 49 #[tracing::instrument(skip_all)]
40 50 pub async fn insert_failed_event(
@@ -63,7 +63,12 @@ pub(super) async fn webhook_v2(
63 63
64 64 // Route by event type
65 65 if thin.event_type.starts_with("v2.core.account") {
66 - handle_account_thin_event(&state, stripe.as_ref(), &thin).await;
66 + if let Err(e) = handle_account_thin_event(&state, stripe.as_ref(), &thin).await {
67 + // Unmark so Stripe retries delivery (retries for up to 3 days)
68 + tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed, unmarking for retry");
69 + let _ = db::webhook_events::unmark_event_processed(&state.db, &thin.id).await;
70 + return Err(e);
71 + }
67 72 } else {
68 73 tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type");
69 74 }
@@ -72,36 +77,28 @@ pub(super) async fn webhook_v2(
72 77 }
73 78
74 79 /// Fetch the full account object and delegate to the shared account-updated handler.
75 - ///
76 - /// On API fetch failure we log a warning and return gracefully — the v2
77 - /// endpoint still returns 200 to acknowledge receipt and prevent Stripe retries.
78 80 async fn handle_account_thin_event(
79 81 state: &AppState,
80 82 stripe: &dyn payments::PaymentProvider,
81 83 thin: &ThinEvent,
82 - ) {
84 + ) -> Result<()> {
83 85 let account_id = match &thin.related_object {
84 86 Some(obj) => &obj.id,
85 87 None => {
86 88 tracing::warn!(event_id = %thin.id, "v2 account event missing related_object");
87 - return;
89 + return Ok(()); // nothing to fetch — acknowledge
88 90 }
89 91 };
90 92
91 - match stripe.fetch_account(account_id).await {
92 - Ok(update) => {
93 - if let Err(e) = super::webhook::handle_account_updated_from_v2(state, &update).await {
94 - tracing::warn!(
95 - account_id = %account_id, error = ?e,
96 - "failed to process account update from v2 event"
97 - );
98 - }
99 - }
100 - Err(e) => {
101 - tracing::warn!(
102 - account_id = %account_id, error = ?e,
103 - "failed to fetch account for v2 event, acknowledging anyway"
104 - );
105 - }
106 - }
93 + let update = stripe.fetch_account(account_id).await.map_err(|e| {
94 + tracing::warn!(account_id = %account_id, error = ?e, "failed to fetch account for v2 event");
95 + e
96 + })?;
97 +
98 + super::webhook::handle_account_updated_from_v2(state, &update).await.map_err(|e| {
99 + tracing::warn!(account_id = %account_id, error = ?e, "failed to process account update from v2 event");
100 + e
101 + })?;
102 +
103 + Ok(())
107 104 }