DEV Community

Rama Pratheeba
Rama Pratheeba

Posted on

How to Make Webhook Processing Idempotent in .NET

Introduction

Webhook endpoints are not guaranteed to be called only once.

In fact, most payment providers and third-party services automatically retry webhook events if:

  • Your API times out
  • Your server returns a non-2xx response
  • Network issues occur

If your .NET application is not idempotent, duplicate webhook calls can cause:

  • Duplicate orders
  • Multiple status updates
  • Data corruption
  • Inconsistent business logic

In production systems, idempotency is not optional — it is essential.

In this guide, we’ll walk through how to implement idempotent webhook processing in .NET (ASP.NET Core).

What Does Idempotent Mean?

An operation is idempotent if executing it multiple times produces the same result as executing it once.

For webhooks, this means:

  • If the same event is delivered twice
  • Your system processes it only once

The Core Problem

Most webhook payloads include a unique event identifier, such as:

  • event_id
  • transaction_id
  • payment_reference

If you ignore this identifier and simply process the request:

// Dangerous: no duplicate protection
await _paymentService.MarkAsPaid(orderId);
Enter fullscreen mode Exit fullscreen mode

Then a retry can execute the same logic twice.

That’s how duplicate processing happens.

Step 1: Store Webhook Event IDs

Create a table to track processed events.

Example table:

WebhookEvents

Id (int)
EventId (string) - unique
ProcessedAt (datetime)
Status (string)
Enter fullscreen mode Exit fullscreen mode

Add a unique constraint to EventId.

This ensures the database itself prevents duplicates.

Step 2: Check Before Processing

Inside your webhook endpoint:

[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook([FromBody] PaymentEvent payload)
{
    var eventId = payload.EventId;

    var exists = await _dbContext.WebhookEvents
        .AnyAsync(e => e.EventId == eventId);

    if (exists)
    {
        return Ok(); // Already processed
    }

    // Process business logic
    await _paymentService.ProcessPayment(payload);

    // Store event record
    _dbContext.WebhookEvents.Add(new WebhookEvent
    {
        EventId = eventId,
        ProcessedAt = DateTime.UtcNow,
        Status = "Processed"
    });

    await _dbContext.SaveChangesAsync();

    return Ok();
}
Enter fullscreen mode Exit fullscreen mode

This ensures each event is processed once.

Step 3: Use Database-Level Protection (Stronger Approach)

Checking first is good.

But the safest approach is:

Let the database enforce uniqueness.

Example using try-catch:

using var transaction = await _dbContext.Database.BeginTransactionAsync();

try
{
    _dbContext.WebhookEvents.Add(new WebhookEvent
    {
        EventId = eventId,
        ProcessedAt = DateTime.UtcNow
    });

    await _paymentService.ProcessPayment(payload);

    await _dbContext.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch (DbUpdateException)
{
    // Duplicate event
    return Ok();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}
Enter fullscreen mode Exit fullscreen mode

Why this is better:

  • Handles race conditions
  • Protects against concurrent requests
  • Production-safe

Step 4: Make Business Logic Safe

Even with event tracking, your business logic should also be safe.

For example:

Instead of blindly updating:

order.Status = "Paid";
Enter fullscreen mode Exit fullscreen mode

Check state transitions:

if (order.Status != "Paid")
{
    order.Status = "Paid";
}

Enter fullscreen mode Exit fullscreen mode

This prevents unintended overwrites.

Step 5: Log Every Webhook Event

For debugging production issues, store:

  • Event ID
  • Previous status
  • New status
  • Timestamp
  • Raw payload reference

This creates a complete audit trail.

When something goes wrong, you can trace the entire lifecycle.

Handling Concurrency (Advanced Consideration)

In high-traffic systems:

  • Multiple webhook retries can arrive simultaneously
  • Horizontal scaling increases risk

Best practices:

  • Use database unique constraints
  • Use transactions
  • Keep webhook processing fast
  • Return HTTP 200 quickly

Never perform heavy operations synchronously inside webhook endpoints.

Common Mistakes to Avoid

❌ Trusting that webhooks are sent once
❌ Not storing event identifiers
❌ Updating business state without checking current state
❌ Performing long-running tasks directly in controller

Production Checklist

Before deploying webhook handling:

  • Unique constraint on EventId
  • Idempotent business logic
  • Structured logging
  • Proper HTTP response handling
  • Monitoring for failures

Closing Thoughts

Webhook retries are a normal part of distributed systems.

Your .NET application must be designed to handle them safely.

Idempotency is not just a technical detail — it is a production reliability requirement.

If you're building payment systems or event-driven APIs in ASP.NET Core, implementing proper idempotent processing should be one of your first architectural decisions.

🎁 Bonus: Production-Ready Webhook Audit Template

If you're implementing this in a real payment or event-driven system, I’ve created a lightweight audit & logging component that you can plug directly into your ASP.NET Core projects.

It includes:

  • Event tracking schema
  • Structured logging setup
  • Retry-safe processing template

You can download it here (currently free for early developers):
https://ramapratheeba.gumroad.com/l/gdzkpw

Top comments (0)