DEV Community

Cover image for Your Stripe handler is shipping orders nobody paid for
Webhooker
Webhooker

Posted on Originally published at webhooker.eu

Your Stripe handler is shipping orders nobody paid for

Here is the Stripe Checkout handler most of us wrote first:

if (event.type === "checkout.session.completed") {
  await fulfilOrder(event.data.object);
}
Enter fullscreen mode Exit fullscreen mode

It passes every test with 4242 4242 4242 4242. It goes live, cards work, everyone moves on.

Then someone in Germany pays with SEPA Direct Debit. The order ships the same minute. Four days later the debit bounces, and you have sent a parcel to someone who never paid for it.

Nothing in that handler is wrong for cards. It just reads the event name as "payment succeeded", and the event does not say that.

completed means the customer finished, not that you got paid

checkout.session.completed fires when the customer submits the Checkout page. Stripe's own description of the session's status: "complete" is careful about this: "The checkout session is complete. Payment processing may still be in progress."

Whether you actually have the money lives in a different field, payment_status:

  • paid: the funds are in your account
  • unpaid: the funds are not there yet
  • no_payment_required: nothing to collect, like a free trial or a 100% coupon

With cards, iDEAL or Bancontact the session completes as paid, so the bug stays invisible. With SEPA Direct Debit, Bacs Direct Debit or a bank transfer, Checkout completes before the bank has said anything, and the session arrives as unpaid. The real outcome shows up days later:

day 0     checkout.session.completed                payment_status: "unpaid"
day 0     payment_intent.processing
day 2-5   payment_intent.succeeded                  (or payment_failed)
day 2-5   checkout.session.async_payment_succeeded  payment_status: "paid"
          (or checkout.session.async_payment_failed)
Enter fullscreen mode Exit fullscreen mode

If you sell in Europe, this is not an edge case. SEPA is a very normal way to pay here.

The obvious fix makes it worse

The first instinct is to switch to payment_intent.succeeded, since that one only fires once the money is confirmed. It does fix SEPA. It also breaks three other things.

Free orders stop working. No money moved, so there is no successful PaymentIntent and the event never fires. The giveaway, the free tier and the 100% launch coupon all complete Checkout and then sit there. Those are exactly the orders nobody looks at until a customer complains.

Subscriptions fulfil twice, then forever. Every paid invoice produces its own payment_intent.succeeded. Your handler cannot tell a new subscriber from their twelfth renewal without extra lookups, and if you kept the session handler too, month one gets processed twice.

The payload has nothing you need. A PaymentIntent knows the amount and currency. It does not know the line items, the address the customer typed into Checkout, your client_reference_id, or the metadata you set on the session. Session metadata is not copied over unless you also pass it as payment_intent_data.metadata. Most handlers built this way end up calling the API to find the Checkout Session again, which is a long way round to the event they started with.

And one more: payment_intent.succeeded is account-wide. If another app on the same Stripe account takes payments, or Billing charges an invoice, your Checkout handler gets those events too.

The rule that covers every case

For Checkout and Payment Links:

  1. Listen to checkout.session.completed and checkout.session.async_payment_succeeded.
  2. Send both into the same fulfilment function.
  3. Fulfil only when payment_status is not unpaid.

The third rule is what makes it work. Card orders pass on the first event. SEPA orders get skipped on the first event and pass on the second. Free orders pass as no_payment_required. Subscriptions fulfil once from Checkout, and renewals go through invoice.paid, where they belong.

Handle checkout.session.async_payment_failed too. That is the moment to cancel the order and email the customer, because nobody else is going to tell them their debit failed.

payment_intent.succeeded is still the right event in one setup: when you build your own form with the Payment Element and create PaymentIntents yourself. There is no Checkout Session there, you put the order ID in the intent's metadata, and you fulfil on payment_intent.succeeded. What you should not do is listen to both for the same order. They describe one payment from two objects, and a handler that reacts to both ships twice.

A handler that fulfils exactly once

Stripe delivers at least once and does not promise any order. async_payment_succeeded can land while you are still processing completed, a retry can bring the same event twice, and the customer's browser can hit your success page while the webhook is still in flight. Here is a version that survives all three:

import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

const fulfilmentEvents = new Set([
  "checkout.session.completed",
  "checkout.session.async_payment_succeeded",
]);

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (request, response) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        request.body,
        request.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return response.sendStatus(400);
    }

    if (fulfilmentEvents.has(event.type)) {
      await queue.add("fulfil-checkout", { sessionId: event.data.object.id });
    } else if (event.type === "checkout.session.async_payment_failed") {
      await queue.add("payment-failed", { sessionId: event.data.object.id });
    }

    response.sendStatus(200);
  },
);

// Called by the queue worker and by the success page.
async function fulfilCheckout(sessionId) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["line_items"],
  });
  if (session.payment_status === "unpaid") return;

  const claimed = await database.query(
    `INSERT INTO fulfilments (checkout_session_id) VALUES ($1)
     ON CONFLICT (checkout_session_id) DO NOTHING
     RETURNING checkout_session_id`,
    [sessionId],
  );
  if (claimed.rowCount === 0) return;

  await shipLineItems(session.line_items.data, session.customer_details);
}
Enter fullscreen mode Exit fullscreen mode

What each piece is there for:

  • express.raw keeps the exact bytes Stripe signed. Parse the JSON first and you get No signatures found matching the expected signature.
  • Enqueue, then answer. When you set a success_url, Checkout waits up to 10 seconds for your endpoint to answer before redirecting the customer. A queue write takes milliseconds. Emails and warehouse calls happen in the worker. Enqueue before the 200, though: answer first, fail to enqueue, and Stripe thinks the event was delivered and never retries.
  • Retrieve the session fresh. The object in the event is a snapshot from when the event was created. Asking the API for the current state means it no longer matters which of the two events arrives first.
  • A unique constraint, not a check. Two concurrent calls both pass a SELECT ... WHERE fulfilled. Only one of them wins the INSERT ... ON CONFLICT DO NOTHING.
  • The success page calls the same function. Put {CHECKOUT_SESSION_ID} in your success_url and call fulfilCheckout when the customer lands. Webhooks can be late, and the unique row makes the second call a no-op.

One caveat on that claim row: if the worker dies after the insert and before shipping, the row says "done" and every retry skips it. If your shipping step is not quick and safe to repeat, record a status on the row and only mark it fulfilled after the work commits.

Testing the path your test cards skip

stripe trigger checkout.session.completed sends a card-shaped event, so it never exercises the delayed path. To see it for real, enable SEPA Direct Debit in your sandbox, go through an actual Checkout, and pay with Stripe's test IBAN AT611904300234573201. You should see checkout.session.completed with payment_status: "unpaid", then checkout.session.async_payment_succeeded shortly after.

If your handler ships on the first one, you have the bug from the top of this post.

Cheat sheet

Your setup Fulfil on Also handle
Checkout, cards only checkout.session.completed + payment_status check checkout.session.expired
Checkout with SEPA, Bacs or bank transfer completed + async_payment_succeeded async_payment_failed
Checkout in subscription mode checkout.session.completed to provision invoice.paid, invoice.payment_failed
Your own PaymentIntents (Payment Element) payment_intent.succeeded payment_intent.payment_failed

The full version, with the field-by-field comparison of both events, the subscription event map and why payment_intent is null on a fresh session, is on our blog: checkout.session.completed vs payment_intent.succeeded.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.