Skip to main content Skip to main content

How to Make Webhook Endpoints Idempotent

· 5 min read

When you build a webhook endpoint, you are operating under a specific contract with the external provider: they will send you data, and you will respond with a 200 OK HTTP status code. If you fail to respond quickly, or if there is a network hiccup, the provider assumes the delivery failed.

Because they want to ensure you get the data, providers like Stripe, Shopify, and GitHub will automatically retry sending the webhook later. This retry mechanism is fantastic for reliability, but it introduces a critical engineering challenge: your endpoint might receive the exact same webhook multiple times.

If your code is not designed to handle duplicate requests safely, a single network delay could result in your application charging a customer twice, sending duplicate welcome emails, or creating multiple identical database records. The solution to this is making your endpoint idempotent.

What is idempotency?

In computer science, an operation is idempotent if performing it multiple times yields the same result as performing it exactly once.

A classic example: "Add 5 to X" is not idempotent. If X is 0, running it once makes X 5. Running it twice makes X 10. Conversely, "Set X to 5" is idempotent. Whether you run it once or one thousand times, the final state is always that X equals 5.

Your webhook handlers must behave like the second example.

The idempotency pattern

To achieve idempotency in a webhook endpoint, you need a way to uniquely identify every incoming event and track whether you have already processed it. Fortunately, almost all webhook providers include a unique "Event ID" in their JSON payload.

Here is the standard pattern for building an idempotent handler:

1. Extract the Event ID

When the request arrives, immediately parse the JSON payload and extract the unique identifier for that specific event. In Stripe, this is the "id" field on the root event object (e.g., "evt_12345").

2. Check your database

Before you execute any business logic, query your database for that Event ID. You should maintain a dedicated table (e.g., "ProcessedWebhooks") specifically for tracking these IDs.

3. Handle duplicates

If the Event ID already exists in your database, it means you have processed this exact webhook before. Do not run any logic. Simply return a 200 OK HTTP response immediately. This tells the provider to stop retrying.

4. Process and record

If the Event ID does not exist, proceed with your standard business logic (updating user accounts, sending emails). Once the logic completes successfully, insert the Event ID into your "ProcessedWebhooks" table and return the 200 OK response.

Idempotency check flowchart A flowchart showing the idempotency check pattern. A webhook arrives, the Event ID is extracted, and a decision is made on whether it was already processed. If yes (left branch), a 200 OK is returned immediately with no work done. If no (right branch), business logic executes, the Event ID is stored in the database, and then a 200 OK is returned. Both paths return 200 OK. Webhook Arrives Extract Event ID Already processed? YES Return 200 OK immediately No work done NO Execute Business Logic Store Event ID in DB Return 200 OK after processing
Both duplicate and first-time deliveries return 200 OK — but only first-time deliveries execute business logic.

Handling race conditions

The basic pattern above works 99% of the time, but it is vulnerable to race conditions. If the provider sends two identical webhooks at the exact same millisecond, your database query in Step 2 might return "not found" for both processes simultaneously. Both processes will then execute the business logic, resulting in duplication.

To solve this, rely on your database's built-in concurrency controls. Make the Event ID column in your "ProcessedWebhooks" table a primary key or apply a unique constraint.

Instead of checking if the ID exists first, attempt to insert the Event ID into the database immediately when the request arrives (often called a "lock"). If the insert fails because of a unique constraint violation, you know another process is currently handling (or has already handled) that event. You can then safely abort the current process.

Testing for idempotency

You must actively test that your code handles duplicates gracefully. This is difficult to do manually using third-party dashboards, as they usually only fire an event once.

The easiest way to test your implementation is using a tool like Payloader. When you capture a webhook in Payloader, you can forward it to your local environment. You can then click the "Replay" button multiple times in rapid succession. This simulates the exact conditions of a retry storm, allowing you to verify that your local application correctly drops the duplicates and only processes the first payload.