This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

A payment webhook sounds simple until a successful payment doesn't actually result in the service the customer paid for.

That was one of the more interesting bugs I encountered while building The Listening Ear, an appointment and online consultation platform.

The requirement was straightforward:

A customer pays for a session → the application confirms the payment → the customer's appointment is booked → a Zoom meeting is created.

The reality was much more complicated.

Project Overview

The Listening Ear connects online payments with appointment scheduling and Zoom-based consultations.

The application was built with technologies including Next.js 14, TypeScript, Supabase, Prisma, PostgreSQL, Zoom, and payment-provider APIs.

The payment workflow was particularly important because payment confirmation was effectively the gatekeeper for the rest of the booking experience.

The intended flow looked like this:

Customer
   │
   ▼
Payment Provider
   │
   │ webhook
   ▼
Next.js Webhook
   │
   ├── Verify / interpret payment
   │
   ├── Create Zoom meeting
   │
   └── Create appointment record
   │
   ▼
Customer receives access to their scheduled session

Enter fullscreen mode Exit fullscreen mode

The problem was that the webhook sat directly in the middle of all of these operations.

Bug Fix or Performance Improvement

The bug appeared when I was implementing the payment webhook that would unlock the Zoom scheduling workflow.

My initial implementation listened for the payment event and checked whether the event was:

if (event === 'charge.success') {

Once that condition was met, the webhook immediately continued into the booking workflow.

That workflow included:

  1. Reading appointment metadata from the payment event.
  2. Handling special emergency appointments.
  3. Building the Zoom meeting payload.
  4. Calling the Zoom meeting API.
  5. Creating the appointment record in the database.
  6. Returning a successful response to the payment provider.

The problem was that all of these operations were effectively coupled to the webhook request.

A payment webhook is an asynchronous external event. It can be retried, delivered more than once, or arrive while another part of the system is still processing.

My implementation initially treated it too much like a normal synchronous API request.

That created a bottleneck:

Payment confirmation → webhook → Zoom API → appointments API → response

If one of those downstream operations was slow or failed, the webhook itself could fail.

That meant the payment provider could retry the webhook.

And then I could potentially end up processing the same successful payment again.

That was the real bug I needed to solve.

The Debugging Journey

The first symptom was easy to misinterpret.

The user had successfully completed payment, but the expected Zoom scheduling experience wasn't always completing correctly.

My first instinct was naturally to look at the Zoom integration.

But following the request backwards exposed a more interesting dependency:

Zoom scheduling
      ▲
      │
Appointment creation
      ▲
      │
Payment webhook
      ▲
      │
Payment provider

Enter fullscreen mode Exit fullscreen mode

The Zoom API wasn't necessarily the original problem.

The payment webhook was responsible for too much.

I started tracing the webhook from the incoming payment event through each downstream operation.

The relevant portion of the original implementation was essentially:

if (event === 'charge.success') {
    // ...

    const res = await axios.post(
        base_url + '/api/create-zoom-meeting',
        zoomPayload
    );

    if (res) {
        const bookedSlotRes = await axios.post(
            base_url + '/api/appointments',
            {
                date: bookingInfo?.appointmentDay,
                time: `${bookingInfo.start}-${bookingInfo.end}`,
                transaction_ref:
                    jsonData?.data?.tx_ref ||
                    jsonData?.data?.reference,
                duration: bookingInfo?.sessionType
            }
        );

        return NextResponse.json({
            message: "Zoom meeting created with booked time slot",
            bookedSlot: bookedSlotRes.data,
            meeting: res.data?.meeting,
            success: true
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

That was the turning point in my investigation.

The webhook wasn't merely acknowledging a payment.

It was also acting as an orchestration layer for two additional services.

The Retry Problem

This is where the issue became particularly interesting.

The webhook endpoint only returned a successful response after the Zoom request and appointment request had completed.

So the lifecycle was effectively:

Payment Provider
      │
      │ charge.success
      ▼
Webhook
      │
      ├──────► Zoom API
      │          │
      │          ▼
      │       Success?
      │
      ├──────► Appointments API
      │          │
      │          ▼
      │       Success?
      │
      ▼
  HTTP 200

Enter fullscreen mode Exit fullscreen mode

If something went wrong before the final response, the handler entered the catch block:

catch (error: any) {
    console.log("Webhook Error:", error);

    return NextResponse.json({
        message: error.message || error || "Error occured handling webhook",
        success: false
    }, { status: 500 });
}

Enter fullscreen mode Exit fullscreen mode

From the application's perspective, that was a reasonable error response.

From the perspective of a payment provider delivering a webhook, however, a 500 can mean:

"The receiving system didn't successfully process this event. Try again."

And that is exactly where retries become dangerous.

A retry could cause the application to attempt to create the Zoom meeting again.

That introduced the possibility of duplicate downstream side effects from a single payment event.

There Was Another Problem

While debugging the webhook, I also revisited the signature-verification portion of the implementation.

The original code contained this:
const stringifiedBody = request.body?.toString() as string;

const hash = crypto
    .createHmac('sha512', secret_key)
    .update(stringifiedBody, 'utf-8')
    .digest('hex');

Enter fullscreen mode Exit fullscreen mode

and then compared the generated hash against the Paystack signature:
request.headers.get('x-paystack-signature')

However, the request body had already been consumed here:
const jsonData = await request.json();

That made the raw request-body handling problematic.

More importantly, the actual signature check had been left commented out:

// if (hash === request.headers.get('x-paystack-signature')) {
// }

Enter fullscreen mode Exit fullscreen mode

That was an important reminder that webhook reliability isn't only about successfully processing an event.

There are two separate questions:

  1. Is this event authentic?
  2. Can my application process it safely and reliably?

Both matter.

The Fix

I reworked the webhook flow around a much clearer separation of responsibilities.
The first principle was:

A payment webhook should establish payment state reliably before downstream services act on it.

Rather than treating charge.success as permission to blindly execute the entire booking workflow, I treated the event as a state transition that needed to be interpreted and handled carefully.

The booking metadata continued to provide the information required for the session:

const bookingInfo: any = jsonData?.data?.metadata;

The implementation also had to account for special appointment types.

For example, emergency appointments required the application to calculate a new time range:

if (bookingInfo.appointmentOption === "emergency") {
    const { start, end } =
        computeTimeRange(bookingInfo.sessionType, 5);

    startTime = start;

    bookingInfo.start = start;
    bookingInfo.end = end;
}

Enter fullscreen mode Exit fullscreen mode

That information then became part of the Zoom meeting payload.

The important change was not simply changing one if statement.

It was making the payment → booking → Zoom relationship explicit and predictable.

Making Webhook Processing Safer

The biggest lesson was that webhook handlers should be designed with retries in mind from the beginning.

I had to think about questions such as:

  • What happens if the same successful payment event arrives twice?
  • What happens if Zoom succeeds but appointment creation fails?
  • What happens if appointment creation succeeds but the webhook response fails?
  • What happens if the provider retries an event after a timeout?
  • How do I prevent duplicate meetings?
  • How do I prevent duplicate appointment records?
  • When is it actually safe to tell the payment provider that processing succeeded?

These aren't unusual edge cases for webhook-driven systems.

They are part of the normal operating environment.

The resulting approach was therefore much more deliberate about payment state, downstream operations, and retry behavior rather than treating the webhook as a one-shot function.

The Zoom Integration Was Only Half the Story

One of the most useful debugging lessons came from separating the visible symptom from the actual source of the problem.

The user experience looked like a Zoom problem:

"I paid, but I can't get my meeting."

But the actual chain was:

Payment
   ↓
Webhook
   ↓
Payment state
   ↓
Booking
   ↓
Zoom meeting

Enter fullscreen mode Exit fullscreen mode

That meant I couldn't reliably fix the issue by staring only at the Zoom integration.

The payment webhook was the system boundary where the external payment provider's state became internal application state.

Once that state was handled correctly, the rest of the workflow became much easier to reason about.

A Small Detail That Also Caught Me

The webhook also handled optional invitees for Zoom meetings:

if (bookingInfo.invites && bookingInfo.invites !== "") {
    const parsedInvites = bookingInfo.invites.split(",");

    parsedInvites.map((invitee: string) => {
        zoomPayload.settings.meeting_invitees.push({
            email: invitee
        });

        zoomPayload.settings.authentication_exception.push({
            name: invitee.split('@')?.[0],
            email: invitee
        });
    });
}

Enter fullscreen mode Exit fullscreen mode

This was another example of why webhook handlers can become deceptively complicated.

A single payment event was carrying metadata that influenced:

  • appointment timing;
  • emergency-session behavior;
  • Zoom meeting configuration;
  • invitees;
  • authentication exceptions;
  • transaction references;
  • and database booking information.

The webhook had effectively become the bridge between several independent parts of the application.

That made keeping the workflow predictable even more important.

The Result

The final goal was straightforward:

Successful payment
        ↓
Reliable payment confirmation
        ↓
Correct appointment state
        ↓
Zoom meeting creation
        ↓
Booked session

Enter fullscreen mode Exit fullscreen mode

Instead of treating the payment webhook as a simple notification endpoint, I learned to treat it as a reliability boundary.

That shift in perspective was the biggest improvement.

What I Learned

This bug taught me something that has stuck with me in subsequent backend work:

External events should never be treated as if they were normal synchronous function calls.

A webhook exists in a different world from your application.

The sender and receiver don't share the same execution context. Network requests can fail. Responses can time out. Events can be retried. A downstream API can succeed while your response fails. And the same event can potentially be delivered more than once.

That means robust webhook processing requires thinking about:

  • authentication and signature verification;
  • explicit payment states;
  • asynchronous delivery;
  • retries;
  • idempotency;
  • duplicate events;
  • partial failures;
  • downstream side effects;
  • and transaction references.

The most important lesson wasn't how to make a payment webhook work.

It was learning how to make the system around the webhook behave predictably when things don't go perfectly.

And that was the bug I was happiest to smash.