#david0_webhooks

1 messages ยท Page 1 of 1 (latest)

hazy egretBOT
#

๐Ÿ‘‹ Welcome to your new thread!

โฒ๏ธ We'll be here soon! Typically we respond in a few minutes, but sometimes we might take a bit longer if the server is busy or if you have a particularly tricky question.

โฑ๏ธ We close idle threads, which makes them read-only. Once a thread is closed it won't be reopened, but you can always start a new thread if you have another question.

๐Ÿ”— This thread will always be available, even after it's closed. You can find it again using Discord's search, or you can save this link: https://discord.com/channels/841573134531821608/1457841150588358799

๐Ÿ“ Have more to share? Add more details, code, screenshots, videos, etc. below.

brazen sorrel
#

To elaborate a bit more, essentially because on invoice.paid the subscription is incomplete with Cash App, there's a bug where users who pay with cash app can have their subscriptions not provisioned correctly since in our DB it's updated then with "incomplete" status on invoice.paid events; i wasn't able to reproduce this locally with cash app on my dev machine, but this has been pretty common with cash app users in production, I'm guessing part of it is also due to webhook events being able to come in out of order.

versed orbit
#

Hi there ๐Ÿ‘‹ if your provisioning is based off the status of your Subscription objects, then I do recommend using the customer.subscription.* Event types so you can check the Subscription's status.

#

I'm taking a closer look at the relation of that Event you shared to the Subscription's lifecycle.

#

Okay, looks like that is the initial Invoice that gets created, but let me know if I'm reading things wrong.

brazen sorrel
#

Yeah so we listen to 'checkout.session.completed' to create a subscription, and also listen to 'customer.subscription.updated' to update subscriptions; At the end of our invoice.paid code we also update the subscription like this:

    const subscription = invoice.subscription as string | null;
    // Fetch latest stripe subscription object
    const [stripeSubscription, customer] = await Promise.all([
      stripe.subscriptions.retrieve(subscription),
      stripe.customers.retrieve(invoice.customer as string),
    ]);

   /* provisioning code in the middle... */

// Update subscription
    const { error: updateError } = await supabaseAdmin
    .from('subscriptions')
    .update({
      currency: stripeSubscription.currency,
      status: stripeSubscription.status,
      default_payment_method: stripeSubscription.default_payment_method,
      current_period_start: new Date(
        stripeSubscription.current_period_start * 1000
      ),
      current_period_end: new Date(
        stripeSubscription.current_period_end * 1000
      ),
      billing_cycle_anchor: new Date(
        stripeSubscription.billing_cycle_anchor * 1000
      ),
      interval: stripeSubscription.items.data[0]?.plan.interval,
    })
    .eq('stripe_subscription_id', stripeSubscription.id);

Looking at the code, I'm not exactly sure if there's even a good reason for it to be updating the subscription here in the invoice.paid event, but that's where the issue comes with the status being set to "incomplete". I think we originally put this here because we assumed it would just be good practice to always update the subscription with the latest state on payments to ensure the subscription is set to "active", but since we're already listening to customer.subscription.updated if every payment has a corresponding customer.subscription.updated event then we should just remove this piece of code.

#

I guess to confirm we can expect every invoice payment that did change the subscription to also fire the subscription.updated event right?

versed orbit
#

The Invoice being paid won't, unless the Subscription first falls out of an active status, but the changing of the billing period will trigger a customer.subscription.updated Event.

Do you need to listen for each payment? Once the Subscription is active you can watch customer.subscription.updated Events, inspecting the previous_attributes, to see if status is changing and if so change your provisioning status accordingly.

brazen sorrel
#

Hmm, we could probably refactor it, but based on the docs https://docs.stripe.com/billing/subscriptions/webhooks it mentioned you can provision access based on the invoice.paid events; this event handler used to be simpler with just the subscription update code, but now there's some other logic in there now for granting out credits to users. We mainly just use customer.subscription.updated just to update the internal subscription so we can correctly show to users the status of it from the account page. I think for now what it sounds like is we don't need to update the subscription here on invoice paid events since if it actually does change it'll come in through the other webhook event.

Handle subscription events including payment failures, status changes, trial endings, and actions requiring customer authentication using webhook endpoints.

#

I think the trip up here is just this wasn't a problem before since other payment methods didn't require customer authentication to complete the payment and so the code assumption was wrong assuming the subscription would always be active whenever the customer paid. I'll just remove and test that and it should all hopefully be fine lol. Thanks for the help digigng through this.

versed orbit
#

Happy to help! If you don't mind, there is one thing I want to clarify, because I want to make sure there isn't a bug here.

When you received that invoice.paid Event, you retrieved the association Subscription object and saw that its status wasn't active?

brazen sorrel
#

Yeah, I've been trying to debug this for a while and so we set up more logging earlier; we don't do any checking on the subscription status when we fetch it from invoice.paid since we always assumed it would be active, but we fetch it from the stripe API to get the freshest copy, so after we fetch it we started logging it:


    logger.info(`Stripe Subscription Found`, {
      data: {
        stripeSubscriptionId: stripeSubscription.id,
        stripeSubscription: JSON.stringify(stripeSubscription),
      },
    });

and it came in with "status":"incomplete". I then checked out other logs we had for this subscription id sub_1Sm1vcB4s21gNWcazsUAHls1 and it came in as "active" for the checkout.session.completed event, so whenever these invoice.paid events come in they overwrite the active status for the created subscription

versed orbit
#

Gotcha, thank you! I'll dig into this a bit more on my side and see whether this is a bug or a miss in our docs.

brazen sorrel
#

I then looked at the events for the customer today, cus_Tj8fNZ0Jbpv31v and noticed that customer.subscription.updated came in after invoice.paid and so what I'm guessing happened is a race condition... we got the invoice.paid event, and it fetches the latest subscription before it's been updated, so we have the "incomplete" status instead of the "active" status when we were updating it internally... and maybe the other subscription update event just didn't come in after this or finished its db update b4 the invoice paid one

#

Though like it's weird, because on the checkout.session.completed, that comes in after invoice.paid and at that point the sub status was active

versed orbit
#

Yup, that's what I'm thinking too. It's why we don't reocmmend assuming you'll get webhook events in a guaranteed order, we just can't promise they'll actually get to your endpoint in a particular order.

#

All of these updates are happening more or less simultaneously

hazy egretBOT
brazen sorrel
#

Yeah I read about that in a github thread and that's why we don't use any objects directly and re-fetch from the API, but the weird thing is, we only create subscriptions on checkout.session.completed and we fetch the stripe subscription from the API again,

  const stripeSubscriptionId = checkoutSession.subscription as string;
  const stripeSubscription =
    await stripe.subscriptions.retrieve(stripeSubscriptionId);

At which point, it got logged as "active" and according to the stripe events, this happens after the subscription was updated (i would share the screen shot but it contains the customer email).

We don't upsert on the invoice.paid, only update if it already exists... so somehow this thing that came in after the subscription has already been updated to 'active' and initially inserted with 'active' fetches the same subscription again but comes in as "incomplete"

#
  1. invoice paid: evt_1Sm1vtB4s21gNWcaBhlLUYkR, Jan 5, 2026, 12:46:05 AM UTC
  2. subscription update: evt_1Sm1vuB4s21gNWcaTfK8EAC5, Jan 5, 2026, 12:46:06 AM UTC
  3. checkout session completed: evt_1Sm1vvB4s21gNWcaQILwvML7, Jan 5, 2026, 12:46:07 AM UTC

I guess what I'm saying is, if we were upserting the subscription on invoice.paid this would make a lot more sense, since sure we could've got this webhook event before the other ones and then created a subscription with an "incomplete" status; but it came in after the subscription has been inserted and updated with "active" status and then somehow fetched an "incomplete" status and overwrote it.

violet vessel
#

๐Ÿ‘‹ Toby has to head out so i'll be taking over the thread. Just getting caught up.