DEV Community

Bedirhan Elibol
Bedirhan Elibol

Posted on

A payment that never unlocked the plan: what our own webhook got wrong

Someone paid for our product and got nothing. The payment went through, the provider sent us the event, and our handler answered with a success code. The customer was charged and their account stayed on the free plan. Nobody got an error, so nobody noticed.

Here is what happened, and the three checks we added so it cannot happen silently again.

The payer had no account yet

The customer paid before they created an account. Our webhook looked up the account by the payer's email, found nothing, and returned 200. From the provider's side the delivery succeeded, so it never retried.

const user = await findUserByEmail(email);
if (!user) {
  logger.warn("no registered user, skipping");
  return NextResponse.json({ received: true }, { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

That 200 is the bug. It says "I handled this", and we did not.

What we changed

  1. Keep the entitlement until it can be used. When no account matches, we store the plan and the paid period in a small table keyed by the lowercased email. When the account is created, the signup step reads that row, writes the plan with the paid period, and removes the row. An expired row is ignored.
  2. Return an error when a write fails. If storing the entitlement fails, the handler returns 500, so the provider retries instead of dropping the event.
  3. Clear it on revoke and refund. A cancellation or refund before signup removes the stored entitlement, so nobody keeps a plan they no longer paid for.

Checks for your own webhooks

  • Ask what the handler does when the customer cannot be matched. If the answer is "return 200", fix that first.
  • Match on the provider's customer or subscription id where you can. Email is a weak key: people pay with a different address than the one they signed up with.
  • Test the path where the payment arrives before the account exists. Most billing bugs live in the order of events, not in the happy path.

These checks come from reading our own code and the events it received. If your webhook answers 200 for things it did not do, it is worth a look this week.

If you want a second pair of eyes on a public repo, the free check at zelsis.com covers the common webhook signature and access mistakes.

Top comments (3)

Collapse
 
posting-dude profile image
Posting Dude •

The key detail is that the 200 made the missing entitlement invisible to the payment provider too. Keeping the paid period until signup seems practical; how do you handle multiple purchases against the same email before an account exists?

Collapse
 
krusenas profile image
Karolis •

Matching on email alone keeps biting people, even with the entitlement saved for signup. Work accounts and plus tags mean the payment address often isn't what they type at signup. Before returning 200, assert the event has the provider customer id. I built Webhook Relay. It stores every request so you can look at what actually arrived.

Collapse
 
challan116ux profile image
challan116-ux •

That 200 is the most dangerous lie a handler can tell, because it retires the provider's retry machinery for work that never happened. The partner-flow equivalent is acknowledging an order you never loaded — everything looks green until a human notices. Parking the entitlement keyed by email and failing loudly when the write fails is exactly the right shape: payment arriving before the account exists is a normal ordering of events, not an edge case, and the system should be built like it expects it.

— Chris, founder of SignalEDI