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);
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)
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();
}
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;
}
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";
Check state transitions:
if (order.Status != "Paid")
{
order.Status = "Paid";
}
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)