Skip to content
Shawon
All posts

4 min read

Your payment webhook will fire twice

Gateways retry when they don't hear a clean answer. If your handler isn't built for that, one payment becomes two accounts, two emails and two deliveries.

The first time I built a checkout, I treated the payment webhook as a notification. Money arrived, the gateway told me, I created the order and sent the confirmation email. Clean, linear, done.

It is not linear. The gateway does not send a notification. It asks a question, and it keeps asking until it gets an answer it understands.

Why it fires more than once

A payment gateway has to be sure you received the result. If it sends the callback and gets a timeout, a 500, or a response it cannot parse, it has no way to know whether you processed the payment or not. The safe assumption on its side is that you did not, so it tries again.

That is the correct design. Losing a payment confirmation is far worse than sending one twice. But it means the duplicate is your problem, not theirs.

The ways you end up with a second call are mundane:

  • Your server was slow and the gateway timed out while you were still working
  • Your handler threw after the database write but before responding
  • Something downstream — an email provider, an SMS gateway — was down, and your error propagated into the response
  • The gateway's own retry schedule, which some run regardless

What a naive handler does on the second call: creates a second account, writes a second order, sends a second confirmation, and in a digital goods store, delivers the product again. The customer paid once.

The record you already have is the guard

You do not need a separate deduplication table. The order is already the source of truth, and it already has a status.

const order = await db.order.findUnique({ where: { transactionId } });

if (!order) {
  return new Response("Unknown transaction", { status: 404 });
}

// এই অংশটাই আসল
if (order.status !== "PENDING") {
  return new Response("Already processed", { status: 200 });
}

await db.order.update({
  where: { id: order.id },
  data: { status: "COMPLETED", paidAt: new Date() },
});

The first call finds a pending order and moves it to completed. Every call after that finds a completed order and stops.

Two details in there matter more than they look.

The second call returns 200, not an error. It is tempting to return 409 because the request is, in a sense, wrong. Do not. A non-2xx tells the gateway to try again, and now you are in a retry loop with a handler that was working correctly. Return success and the gateway stops.

The status change is the first thing you do. Not the email, not the SMS. Those come after, and we will get to why.

Everything after the money is a separate risk

Once a payment has succeeded, the order must be marked paid. That is non-negotiable — the money has already moved, and the record has to match reality.

The confirmation email is not in that category. Neither is the SMS, the Slack notification, or the analytics event. Those are nice. They are also the parts most likely to fail, because they depend on other people's servers.

So the order is updated first, and each side effect is wrapped on its own:

await db.order.update({ /* ... */ });   // এটা ব্যর্থ হলে সব থামবে

try {
  await sendConfirmationEmail(order);
} catch (error) {
  console.error("Confirmation email failed:", order.id, error);
}

try {
  await sendSms(order);
} catch (error) {
  console.error("SMS failed:", order.id, error);
}

If the email provider is down, the order is still correctly marked paid and the customer still has what they bought. You can resend an email later. You cannot recover an order you dropped.

Wrap the whole handler in one try instead and a failing email throws, the gateway sees an error, retries — and finds an order that is already completed, so the email never gets sent at all. The one thing you were trying to protect is the one thing you lose.

Log the identifier, not just the error

console.error("Email failed") tells you something broke. console.error("Confirmation email failed:", order.id, error) tells you which customer to follow up with.

The first costs you an afternoon reading logs. The second costs you nothing and turns a vague alert into a one-line fix.

Testing it

You do not need a failed payment to test this. Replay a successful one.

Most gateways show the callback payload in their dashboard. Copy it, post it to your endpoint a second time, and check that nothing changed: no second order, no second email, no second delivery. If something did, the guard is in the wrong place or missing.

Then do it a third time. Retries are not always limited to two.

The shape worth remembering

The question a webhook handler answers is not "did the gateway call me?" It is "has this payment already been processed?"

Once you frame it that way, the structure writes itself: look up the record, check its state, act only if it needs acting on, and always answer with a success the sender can understand. Everything else is detail.

backend · payments · webhooks