Webhooks have become a standard mechanism for integrating systems in real time. Whether you are building event-driven microservices, syncing with a third-party SaaS platform, or triggering automation workflows, webhooks provide a simple and scalable solution.
This article provides a fully practical, production-grade guide to building a Webhook Sender + Webhook Receiver architecture using ASP.NET Core 8, including payload design, security (HMAC signatures), retries, logging, and monitoring.
It also includes architecture diagrams, sequence diagrams, and example code.
Architecture Diagram
┌──────────────────┐ HTTP POST Webhook ┌─────────────────────┐
│ .NET Webhook │──────────────────────────────────▶│ Webhook Receiver │
│ Sender API │ │ (.NET Core API) │
└───────┬───────────┘ └─────────┬───────────┘
│ │
│ Logs event + delivery attempts │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ WebhookEvents Table │ │ DeliveryLogs Table │
└──────────────────────┘ └──────────────────────┘
Use Case
Your system triggers internal events—example:
OrderCreated
PaymentProcessed
StockAdjusted
UserRegistered
External systems or partners need to be notified when the events occur.
A Webhook Sender system pushes events, and a Receiver API processes them.
Webhook Components
Sender Side
Exposes a Webhook Registration API.
Stores third-party endpoints and secrets.
Sends HTTP POST requests with JSON payloads.
Generates a SHA256 HMAC signature per request.
Handles retries with exponential backoff.
Logs delivery attempts.
Receiver Side
Database Schema (Sender Side)
Table: WebhookSubscribers
| Field | Type |
|---|
| Id | INT PK |
| CallbackUrl | VARCHAR(500) |
| SecretKey | VARCHAR(200) |
| IsActive | BIT |
| EventType | VARCHAR(200) |
| CreatedDate | DATETIME |
Table: WebhookEvents
| Field | Type |
|---|
| Id | INT PK |
| EventType | VARCHAR(200) |
| PayloadJson | NVARCHAR(MAX) |
| CreatedDate | DATETIME |
Table: DeliveryLogs
| Field | Type |
|---|
| Id | INT PK |
| EventId | INT FK |
| SubscriberId | INT FK |
| Attempt | INT |
| StatusCode | INT |
| ResponseBody | NVARCHAR(MAX) |
| DeliveredAt | DATETIME |
Sequence Diagram
Sender System Receiver System
| |
|-- Event Occurs -------------------->|
| |
|-- Save Webhook Event -------------->DB
|-- Send HTTP POST with HMAC--------->|
| |-- Validate Signature
| |-- Process Payload
| |-- Save Response
|<------------- 200 OK ---------------|
|
|-- Log Delivery Attempt ------------>DB
Webhook Sender Implementation (ASP.NET Core)
Step 1: Generate HMAC Signature
public static string GenerateSignature(string secret, string body)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var bodyBytes = Encoding.UTF8.GetBytes(body);
using var hmac = new HMACSHA256(keyBytes);
var hash = hmac.ComputeHash(bodyBytes);
return Convert.ToBase64String(hash);
}
Step 2: Send Webhook Notification
public async Task<bool> SendWebhookAsync(WebhookSubscriber sub, WebhookEvent evt)
{
var payload = evt.PayloadJson;
var signature = GenerateSignature(sub.SecretKey, payload);
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-WEBHOOK-SIGNATURE", signature);
client.DefaultRequestHeaders.Add("X-WEBHOOK-EVENT", evt.EventType);
var response = await client.PostAsync(sub.CallbackUrl,
new StringContent(payload, Encoding.UTF8, "application/json"));
await LogDelivery(sub.Id, evt.Id, response);
return response.IsSuccessStatusCode;
}
Step 3: Retry Logic (Exponential Backoff)
Retry intervals:
1st retry → after 1 minute
2nd retry → after 5 minutes
3rd retry → after 30 minutes
Final → after 2 hours
public static readonly int[] RetryIntervalsMinutes = { 1, 5, 30, 120 };
Schedule retries using:
Hangfire
Quartz.NET
BackgroundService
Webhook Receiver Implementation (ASP.NET Core)
Step 1: Controller
[ApiController]
[Route("api/webhook")]
public class WebhookReceiverController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Receive([FromBody] JsonElement payload)
{
var signature = Request.Headers["X-WEBHOOK-SIGNATURE"].ToString();
var eventType = Request.Headers["X-WEBHOOK-EVENT"].ToString();
var rawBody = await new StreamReader(Request.Body).ReadToEndAsync();
if (!SignatureValidator.IsValid(signature, rawBody, "MyReceiverSecretKey"))
return Unauthorized("Invalid Signature");
// Idempotency check
if (await IsDuplicateAsync(rawBody))
return Ok("Duplicate ignored");
await SaveWebhookMessage(eventType, rawBody);
return Ok("Received");
}
}
Step 2: Signature Validation
public static class SignatureValidator
{
public static bool IsValid(string receivedSignature, string payload, string secret)
{
var expected = GenerateSignature(secret, payload);
return receivedSignature == expected;
}
}
Idempotency Handling
To avoid duplicate processing, hash the payload and save it:
CREATE TABLE WebhookReceived (
Id INT IDENTITY PRIMARY KEY,
PayloadHash VARCHAR(200),
EventType VARCHAR(100),
RawPayload NVARCHAR(MAX),
ReceivedDate DATETIME
);
Best Practices
1. Always Use HTTPS
Webhook endpoints must enforce TLS.
2. Validate Signatures
Never trust the body without verifying the secret.
3. Implement Retries
Transient failures happen. Never drop events silently.
4. Log Everything
Payload
Attempt number
Status code
Response body
5. Provide a Replay UI
Allow users to replay failed webhook deliveries.
6. Create a Dashboard
Show:
Delivered
Pending retries
Failed
Average latency
Sample Payload Format
{"eventId": "98f90312-9c0b-4f3d-b34a-c88a3a95f8d9","eventType": "OrderCreated","timestamp": "2025-01-10T12:30:00Z","data": {
"orderId": 4571,
"customerId": 102,
"amount": 129.99}}
Complete Workflow Summary
Event occurs in your system.
Save event into WebhookEvents.
Load subscribers for that event type.
For each subscriber:
Generate HMAC signature
POST the payload
If failed, queue retry
Receiver validates signature
Receiver processes payload safely
Logs all actions
This system ensures reliable, secure, end-to-end delivery of webhook messages.
Source Code
All code is practical and production-minded (HMAC signing, persistence, retry with exponential backoff, idempotency, logging). Replace connection strings, secrets and email/HTTP endpoints for your environment.
How to use this package
Create two projects (separate folders): WebhookSender and WebhookReceiver.
Create a SQL Server database and run the provided SQL scripts (tables + stored procedures).
Update appsettings.json in each project with your DefaultConnection and secrets.
Build and run both APIs.
Import the included Postman collection and test flows.
Open the dashboard HTML file to see a simple UI mockup (can be replaced by an Angular app).
1) Database: Schema + Stored Procedures
Run this on your SQL Server. It creates schema for both sender side and receiver side (logs, delivery attempts), plus stored procedures used by the Sender background worker.
-- ---------- Database: webhook_db ----------
-- Run in your target database.
-- Sender side tables
CREATE TABLE WebhookSubscribers (
SubscriberId UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY,
CallbackUrl NVARCHAR(1000) NOT NULL,
SecretKey NVARCHAR(200) NOT NULL, -- HMAC secret for this subscriber
IsActive BIT NOT NULL DEFAULT 1,
EventType NVARCHAR(200) NOT NULL,
CreatedOn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE WebhookEvents (
EventId UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY,
EventType NVARCHAR(200) NOT NULL,
PayloadJson NVARCHAR(MAX) NOT NULL,
CreatedOn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE DeliveryAttempts (
AttemptId UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY,
EventId UNIQUEIDENTIFIER NOT NULL,
SubscriberId UNIQUEIDENTIFIER NOT NULL,
AttemptNumber INT NOT NULL DEFAULT 0,
NextAttemptAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
Status NVARCHAR(50) NOT NULL DEFAULT 'Pending', -- Pending, Delivered, Failed
StatusCode INT NULL,
ResponseBody NVARCHAR(MAX) NULL,
LastError NVARCHAR(MAX) NULL,
CreatedOn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedOn DATETIME2 NULL,
CONSTRAINT FK_Delivery_Event FOREIGN KEY (EventId) REFERENCES WebhookEvents(EventId),
CONSTRAINT FK_Delivery_Subscriber FOREIGN KEY (SubscriberId) REFERENCES WebhookSubscribers(SubscriberId)
);
CREATE INDEX IX_DeliveryAttempts_Status_NextAttempt ON DeliveryAttempts (Status, NextAttemptAt);
CREATE INDEX IX_DeliveryAttempts_Event ON DeliveryAttempts (EventId);
-- Receiver side table (idempotency + received logs)
CREATE TABLE WebhookReceived (
ReceivedId UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY,
PayloadHash NVARCHAR(200) NOT NULL,
EventType NVARCHAR(200) NULL,
RawPayload NVARCHAR(MAX) NOT NULL,
IsProcessed BIT NOT NULL DEFAULT 0,
ReceivedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE INDEX IX_WebhookReceived_PayloadHash ON WebhookReceived(PayloadHash);
-- ---------- Stored Procedures (Sender) ----------
-- 1) Register subscriber
CREATE PROCEDURE sp_RegisterSubscriber
@CallbackUrl NVARCHAR(1000),
@SecretKey NVARCHAR(200),
@EventType NVARCHAR(200)
AS
BEGIN
INSERT INTO WebhookSubscribers (CallbackUrl, SecretKey, EventType)
VALUES (@CallbackUrl, @SecretKey, @EventType);
SELECT TOP 1 * FROM WebhookSubscribers WHERE SubscriberId = SCOPE_IDENTITY();
END;
GO
-- 2) Insert event and create delivery attempts for all matching subscribers
CREATE PROCEDURE sp_CreateEventAndQueueDeliveries
@EventType NVARCHAR(200),
@PayloadJson NVARCHAR(MAX)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @EventId UNIQUEIDENTIFIER = NEWID();
INSERT INTO WebhookEvents (EventId, EventType, PayloadJson)
VALUES (@EventId, @EventType, @PayloadJson);
INSERT INTO DeliveryAttempts (EventId, SubscriberId, NextAttemptAt, AttemptNumber, Status)
SELECT @EventId, SubscriberId, SYSUTCDATETIME(), 0, 'Pending'
FROM WebhookSubscribers
WHERE IsActive = 1 AND EventType = @EventType;
SELECT @EventId AS EventId;
END;
GO
-- 3) Get pending attempts ready to send (for the background worker)
CREATE PROCEDURE sp_GetPendingAttempts
@MaxRows INT = 50
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (@MaxRows) da.AttemptId, da.EventId, da.SubscriberId, da.AttemptNumber, da.NextAttemptAt,
we.PayloadJson, ws.CallbackUrl, ws.SecretKey
FROM DeliveryAttempts da
JOIN WebhookEvents we ON we.EventId = da.EventId
JOIN WebhookSubscribers ws ON ws.SubscriberId = da.SubscriberId
WHERE da.Status = 'Pending' AND da.NextAttemptAt <= SYSUTCDATETIME()
ORDER BY da.NextAttemptAt ASC;
END;
GO
-- 4) Mark attempt result (success or failure and provide nextAttempt scheduling)
CREATE PROCEDURE sp_UpdateAttemptResult
@AttemptId UNIQUEIDENTIFIER,
@Status NVARCHAR(50), -- Delivered or Pending or Failed
@AttemptNumber INT,
@NextAttemptAt DATETIME2 = NULL,
@StatusCode INT = NULL,
@ResponseBody NVARCHAR(MAX) = NULL,
@LastError NVARCHAR(MAX) = NULL
AS
BEGIN
UPDATE DeliveryAttempts
SET Status = @Status,
AttemptNumber = @AttemptNumber,
NextAttemptAt = ISNULL(@NextAttemptAt, NextAttemptAt),
StatusCode = @StatusCode,
ResponseBody = @ResponseBody,
LastError = @LastError,
UpdatedOn = SYSUTCDATETIME()
WHERE AttemptId = @AttemptId;
END;
GO
-- 5) Replay a failed attempt: set status back to Pending and schedule immediate retry
CREATE PROCEDURE sp_RequeueAttempt
@AttemptId UNIQUEIDENTIFIER
AS
BEGIN
UPDATE DeliveryAttempts
SET Status = 'Pending',
NextAttemptAt = SYSUTCDATETIME(),
UpdatedOn = SYSUTCDATETIME()
WHERE AttemptId = @AttemptId;
END;
GO
-- ---------- Optional cleanup job ----------
-- Remove old delivered attempts older than X days
CREATE PROCEDURE sp_CleanupDeliveriesOlderThan
@Days INT
AS
BEGIN
DELETE FROM DeliveryAttempts WHERE Status = 'Delivered' AND UpdatedOn < DATEADD(day, -@Days, SYSUTCDATETIME());
END;
GO
Notes:
2) Sender API — Full Project (core files)
Project name: WebhookSender
Below are the essential files. Create an ASP.NET Core Web API project and replace/add these files.
File: appsettings.json (replace connection string, base URL)
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=webhook_db;Trusted_Connection=True;"
},
"Sender": {
"WorkerPollIntervalSeconds": 15,
"MaxBatchSize": 20
},
"Logging": { "LogLevel": { "Default": "Information" } }
}
File: Models.cs
public record WebhookSubscriber(Guid SubscriberId, string CallbackUrl, string SecretKey, string EventType);
public class WebhookEvent { public Guid EventId { get; set; } public string EventType { get; set; } = ""; public string PayloadJson { get; set; } = ""; }
public class DeliveryAttemptDto { public Guid AttemptId { get; set; } public Guid EventId { get; set; } public Guid SubscriberId { get; set; } public int AttemptNumber { get; set; } public string PayloadJson { get; set; } = ""; public string CallbackUrl { get; set; } = ""; public string SecretKey { get; set; } = ""; }
File: Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IDbService, DbService>(); // lightweight DB wrapper below
builder.Services.AddSingleton<IWebhookSender, WebhookSender>(); // service to send HTTP
builder.Services.AddHostedService<DeliveryBackgroundService>(); // background poller & sender
var app = builder.Build();
app.UseSwagger(); app.UseSwaggerUI();
app.MapControllers();
app.Run();
File: DbService.cs (simple ADO.NET wrapper using stored procedures)
using System.Data;
using System.Data.SqlClient;
using Dapper;
public interface IDbService
{
Task<Guid> CreateEventAndQueue(string eventType, string payload);
Task<IEnumerable<DeliveryAttemptDto>> GetPendingAttempts(int maxRows);
Task UpdateAttemptResult(Guid attemptId, string status, int attemptNumber, DateTime? nextAttemptAt, int? statusCode, string? responseBody, string? lastError);
Task<Guid> RegisterSubscriber(string callbackUrl, string secretKey, string eventType);
Task<IEnumerable<WebhookSubscriber>> GetSubscribers(string eventType);
Task<IEnumerable<object>> QueryDeliveryAttemptsPaged(int page, int pageSize);
}
public class DbService : IDbService
{
private readonly IConfiguration _cfg;
public DbService(IConfiguration cfg) { _cfg = cfg; }
private IDbConnection CreateConn() => new SqlConnection(_cfg.GetConnectionString("DefaultConnection"));
public async Task<Guid> CreateEventAndQueue(string eventType, string payload)
{
using var conn = CreateConn();
var p = new DynamicParameters();
p.Add("@EventType", eventType);
p.Add("@PayloadJson", payload);
var res = await conn.QueryFirstOrDefaultAsync<Guid>("sp_CreateEventAndQueueDeliveries", p, commandType: CommandType.StoredProcedure);
return res;
}
public async Task<IEnumerable<DeliveryAttemptDto>> GetPendingAttempts(int maxRows)
{
using var conn = CreateConn();
var p = new DynamicParameters(); p.Add("@MaxRows", maxRows);
return await conn.QueryAsync<DeliveryAttemptDto>("sp_GetPendingAttempts", p, commandType: CommandType.StoredProcedure);
}
public async Task UpdateAttemptResult(Guid attemptId, string status, int attemptNumber, DateTime? nextAttemptAt, int? statusCode, string? responseBody, string? lastError)
{
using var conn = CreateConn();
var p = new DynamicParameters();
p.Add("@AttemptId", attemptId);
p.Add("@Status", status);
p.Add("@AttemptNumber", attemptNumber);
p.Add("@NextAttemptAt", nextAttemptAt);
p.Add("@StatusCode", statusCode);
p.Add("@ResponseBody", responseBody);
p.Add("@LastError", lastError);
await conn.ExecuteAsync("sp_UpdateAttemptResult", p, commandType: CommandType.StoredProcedure);
}
public async Task<Guid> RegisterSubscriber(string callbackUrl, string secretKey, string eventType)
{
using var conn = CreateConn();
var p = new DynamicParameters();
p.Add("@CallbackUrl", callbackUrl);
p.Add("@SecretKey", secretKey);
p.Add("@EventType", eventType);
var inserted = await conn.ExecuteScalarAsync<Guid>("INSERT INTO WebhookSubscribers (CallbackUrl, SecretKey, EventType) OUTPUT INSERTED.SubscriberId VALUES (@CallbackUrl,@SecretKey,@EventType)", p);
return inserted;
}
public async Task<IEnumerable<WebhookSubscriber>> GetSubscribers(string eventType)
{
using var conn = CreateConn();
return await conn.QueryAsync<WebhookSubscriber>("SELECT SubscriberId, CallbackUrl, SecretKey, EventType FROM WebhookSubscribers WHERE EventType=@EventType AND IsActive=1", new { EventType = eventType });
}
public async Task<IEnumerable<object>> QueryDeliveryAttemptsPaged(int page, int pageSize)
{
using var conn = CreateConn();
var sql = @"SELECT da.*, we.PayloadJson, ws.CallbackUrl FROM DeliveryAttempts da
JOIN WebhookEvents we ON we.EventId = da.EventId
JOIN WebhookSubscribers ws ON ws.SubscriberId = da.SubscriberId
ORDER BY da.UpdatedOn DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
return await conn.QueryAsync<object>(sql, new { Skip = (page-1)*pageSize, Take = pageSize });
}
}
Note: This sample uses Dapper for brevity; you can use EF Core if preferred.
File: WebhookSender.cs (HTTP sender + HMAC)
public interface IWebhookSender { Task<bool> SendAsync(DeliveryAttemptDto attempt, CancellationToken ct); string ComputeSignature(string secret, string body); }
public class WebhookSender : IWebhookSender
{
private readonly HttpClient _client;
public WebhookSender() { _client = new HttpClient(); }
public string ComputeSignature(string secret, string body)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var bodyBytes = Encoding.UTF8.GetBytes(body);
using var hmac = new HMACSHA256(keyBytes);
var hash = hmac.ComputeHash(bodyBytes);
return Convert.ToBase64String(hash);
}
public async Task<bool> SendAsync(DeliveryAttemptDto attempt, CancellationToken ct)
{
var payload = attempt.PayloadJson;
var sig = ComputeSignature(attempt.SecretKey, payload);
var req = new HttpRequestMessage(HttpMethod.Post, attempt.CallbackUrl);
req.Content = new StringContent(payload, Encoding.UTF8, "application/json");
req.Headers.Add("X-WEBHOOK-SIGNATURE", sig);
req.Headers.Add("X-WEBHOOK-EVENT", attempt.EventId.ToString());
try
{
var resp = await _client.SendAsync(req, ct);
var body = await resp.Content.ReadAsStringAsync();
return resp.IsSuccessStatusCode;
}
catch
{
return false;
}
}
}
File: DeliveryBackgroundService.cs (hosted service that polls DB)
public class DeliveryBackgroundService : BackgroundService
{
private readonly IDbService _db;
private readonly IWebhookSender _sender;
private readonly ILogger<DeliveryBackgroundService> _logger;
private readonly int[] RetryMinutes = new[] { 1, 5, 30, 120 }; // exponential-ish
public DeliveryBackgroundService(IDbService db, IWebhookSender sender, ILogger<DeliveryBackgroundService> logger, IConfiguration cfg)
{
_db = db; _sender = sender; _logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var attempts = (await _db.GetPendingAttempts(50)).ToList();
foreach (var a in attempts)
{
if (stoppingToken.IsCancellationRequested) break;
int nextAttemptNumber = a.AttemptNumber + 1;
bool success = false;
int? statusCode = null;
string? responseBody = null;
string? lastError = null;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
success = await _sender.SendAsync(a, cts.Token);
// For demo we do not capture response body/status code; expand if needed.
}
catch (Exception ex)
{
lastError = ex.Message;
}
if (success)
{
await _db.UpdateAttemptResult(a.AttemptId, "Delivered", nextAttemptNumber, null, 200, "Delivered", null);
}
else
{
// schedule next attempt based on nextAttemptNumber
DateTime nextAt = DateTime.UtcNow.AddMinutes(RetryMinutes[Math.Min(nextAttemptNumber-1, RetryMinutes.Length-1)]);
await _db.UpdateAttemptResult(a.AttemptId, "Pending", nextAttemptNumber, nextAt, null, null, lastError);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Delivery worker error");
}
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
}
}
}
File: Controllers/WebhookController.cs (exposes endpoints to register subscriber and create events)
[ApiController]
[Route("api/sender")]
public class WebhookController : ControllerBase
{
private readonly IDbService _db;
public WebhookController(IDbService db) { _db = db; }
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
var id = await _db.RegisterSubscriber(dto.CallbackUrl, dto.SecretKey, dto.EventType);
return Ok(new { SubscriberId = id });
}
[HttpPost("event")]
public async Task<IActionResult> CreateEvent([FromBody] EventDto dto)
{
var eventId = await _db.CreateEventAndQueue(dto.EventType, dto.PayloadJson);
return Ok(new { EventId = eventId });
}
[HttpGet("deliveries")]
public async Task<IActionResult> GetDeliveries([FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var res = await _db.QueryDeliveryAttemptsPaged(page, pageSize);
return Ok(res);
}
[HttpPost("requeue/{attemptId}")]
public async Task<IActionResult> Requeue(Guid attemptId)
{
using var conn = new SqlConnection(/*...*/);
await conn.ExecuteAsync("sp_RequeueAttempt", new { AttemptId = attemptId }, commandType: CommandType.StoredProcedure);
return Ok();
}
}
public record RegisterDto(string CallbackUrl, string SecretKey, string EventType);
public record EventDto(string EventType, string PayloadJson);
3) Receiver API — Full Project (core files)
Project name: WebhookReceiver
This API validates HMAC signature and stores received payloads (idempotency) in WebhookReceived.
File: appsettings.json
{
"Receiver": {
"ExpectedSecretKey": "RECEIVER_SECRET_KEY"
},
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=webhook_db;Trusted_Connection=True;"
}
}
File: Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<IReceiverStore, ReceiverStore>(); // ADO.NET or Dapper wrapper
var app = builder.Build();
app.MapControllers();
app.Run();
File: ReceiverStore.cs (simple Dapper wrapper)
public interface IReceiverStore { Task<bool> IsDuplicateAsync(string payloadHash); Task SaveReceived(string payloadHash, string eventType, string rawPayload); }
public class ReceiverStore : IReceiverStore
{
private readonly IConfiguration _cfg;
public ReceiverStore(IConfiguration cfg) { _cfg = cfg; }
private IDbConnection CreateConn() => new SqlConnection(_cfg.GetConnectionString("DefaultConnection"));
public async Task<bool> IsDuplicateAsync(string payloadHash)
{
using var conn = CreateConn();
var c = await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM WebhookReceived WHERE PayloadHash=@h", new { h = payloadHash });
return c > 0;
}
public async Task SaveReceived(string payloadHash, string eventType, string rawPayload)
{
using var conn = CreateConn();
await conn.ExecuteAsync("INSERT INTO WebhookReceived (PayloadHash, EventType, RawPayload) VALUES (@h,@t,@p)", new { h = payloadHash, t = eventType, p = rawPayload });
}
}
File: Controllers/ReceiverController.cs
[ApiController]
[Route("api/receiver")]
public class ReceiverController : ControllerBase
{
private readonly IConfiguration _cfg;
private readonly IReceiverStore _store;
public ReceiverController(IConfiguration cfg, IReceiverStore store) { _cfg = cfg; _store = store; }
[HttpPost]
public async Task<IActionResult> Receive()
{
// read body raw
using var sr = new StreamReader(Request.Body);
var raw = await sr.ReadToEndAsync();
var signature = Request.Headers["X-WEBHOOK-SIGNATURE"].ToString();
var eventHeader = Request.Headers["X-WEBHOOK-EVENT"].ToString();
// find Subscriber secret map or use a global expected secret (demo uses global)
var secret = _cfg["Receiver:ExpectedSecretKey"];
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
return Unauthorized();
var computed = ComputeSignature(secret, raw);
if (!CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(signature), Convert.FromBase64String(computed)))
return Unauthorized("Invalid signature");
// idempotency
var payloadHash = ComputeHash(raw);
if (await _store.IsDuplicateAsync(payloadHash))
return Ok(new { message = "duplicate ignored" });
// persist and process (fire and forget processing can be added)
await _store.SaveReceived(payloadHash, eventHeader, raw);
// process payload business logic here or push to queue
return Ok(new { message = "received" });
}
private string ComputeSignature(string secret, string body)
{
var key = Encoding.UTF8.GetBytes(secret);
var data = Encoding.UTF8.GetBytes(body);
using var hmac = new HMACSHA256(key);
return Convert.ToBase64String(hmac.ComputeHash(data));
}
private string ComputeHash(string body)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(body));
return Convert.ToBase64String(bytes);
}
}
In production you should map subscriber → secret (from DB) and validate per-subscriber; demo uses static shared secret for simplicity.
4) Postman Collection (importable JSON)
Save the block below into Webhook.postman_collection.json and import into Postman.
{
"info": { "name": "Webhook System", "_postman_id": "webhooks-demo", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{
"name": "Register Subscriber",
"request": {
"method": "POST",
"header": [{ "key":"Content-Type","value":"application/json" }],
"body": { "mode":"raw","raw":"{ \"callbackUrl\": \"http://localhost:5001/api/receiver\", \"secretKey\":\"subscriber-secret-123\", \"eventType\":\"OrderCreated\" }" },
"url": { "raw":"http://localhost:5000/api/sender/register","protocol":"http","host":["localhost"],"port":"5000","path":["api","sender","register"] }
}
},
{
"name": "Create Event",
"request": {
"method": "POST",
"header": [{ "key":"Content-Type","value":"application/json" }],
"body": { "mode":"raw","raw":"{ \"eventType\":\"OrderCreated\", \"payloadJson\": \"{ \\\"orderId\\\": 123, \\\"amount\\\": 100.5 }\" }" },
"url": { "raw":"http://localhost:5000/api/sender/event","protocol":"http","host":["localhost"],"port":"5000","path":["api","sender","event"] }
}
},
{
"name": "List Deliveries",
"request": {
"method": "GET",
"url": { "raw":"http://localhost:5000/api/sender/deliveries","protocol":"http","host":["localhost"],"port":"5000","path":["api","sender","deliveries"] }
}
},
{
"name": "Receiver Endpoint (simulate direct call)",
"request": {
"method": "POST",
"header": [{ "key":"Content-Type","value":"application/json" }, { "key":"X-WEBHOOK-SIGNATURE","value":"<signature>" }],
"body": { "mode":"raw","raw":"{ \"eventId\":\"abc\",\"orderId\":123 }" },
"url": { "raw":"http://localhost:5001/api/receiver","protocol":"http","host":["localhost"],"port":"5001","path":["api","receiver"] }
}
}
]
}
Usage notes
Run WebhookSender on port 5000 and WebhookReceiver on port 5001 (or adjust URLs).
Use Register Subscriber to create an endpoint and secret. The Sender service uses the DB to queue deliveries.
Create Event will queue attempts. Background service polls DB and attempts delivery.
5) UI Dashboard Mockup (HTML + CSS)
A simple static dashboard allowing you to view recent deliveries, requeue attempts and see statuses. Save as dashboard.html and open in browser. It uses the Sender API endpoints.
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Webhook Dashboard</title>
<style>
body { font-family: Arial, Helvetica, sans-serif; margin: 16px; background:#f6f7fb; color:#222; }
.card { background: #fff; border-radius: 6px; padding: 12px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
table { width:100%; border-collapse: collapse; }
th, td { padding:8px; border-bottom:1px solid #eee; text-align:left; font-size:14px; }
th { background:#fafafa; font-weight:600; }
.badge { padding:4px 8px; border-radius:12px; font-size:12px; }
.badge.pending { background:#fff3cd; color:#856404; }
.badge.delivered { background:#d4edda; color:#155724; }
.badge.failed { background:#f8d7da; color:#721c24; }
.btn { padding:6px 10px; border-radius:4px; border:none; cursor:pointer; }
.btn.primary { background:#1976d2; color:white; }
.btn.ghost { background:transparent; border:1px solid #ddd; }
.controls { display:flex; gap:8px; margin-bottom:12px; }
</style>
</head>
<body>
<h3>Webhook Sender — Dashboard</h3>
<div class="card">
<div class="controls">
<button class="btn primary" onclick="refresh()">Refresh</button>
<button class="btn ghost" onclick="requeueSelected()">Requeue Selected</button>
<input id="filter" placeholder="Filter event type..." oninput="refresh()" />
</div>
<table id="tbl">
<thead>
<tr><th></th><th>AttemptId</th><th>EventId</th><th>Subscriber</th><th>Attempt</th><th>Status</th><th>NextAttempt</th><th>Action</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
async function refresh(){
const tbody = document.querySelector('#tbl tbody');
tbody.innerHTML = '<tr><td colspan="8">Loading...</td></tr>';
try{
const res = await fetch('/api/sender/deliveries?page=1&pageSize=50');
const data = await res.json();
tbody.innerHTML = '';
data.forEach(row => {
const tr = document.createElement('tr');
const status = row.Status?.toLowerCase() || 'pending';
tr.innerHTML = `
<td><input type="checkbox" data-id="${row.AttemptId}"/></td>
<td>${row.AttemptId}</td>
<td>${row.EventId}</td>
<td>${row.CallbackUrl}</td>
<td>${row.AttemptNumber}</td>
<td><span class="badge ${status}">${row.Status}</span></td>
<td>${row.NextAttemptAt ?? ''}</td>
<td><button class="btn" onclick="requeue('${row.AttemptId}')">Requeue</button></td>`;
tbody.appendChild(tr);
});
} catch(e){
tbody.innerHTML = '<tr><td colspan="8">Error loading data</td></tr>';
}
}
async function requeue(id){
await fetch(`/api/sender/requeue/${id}`, { method:'POST' });
refresh();
}
async function requeueSelected(){
const checked = Array.from(document.querySelectorAll('input[type=checkbox]:checked')).map(x => x.dataset.id);
for(const id of checked){ await fetch(`/api/sender/requeue/${id}`, { method:'POST' }); }
refresh();
}
refresh();
</script>
</body>
</html>
Notes
This mockup directly calls /api/sender/deliveries and /api/sender/requeue/{id}; to use it locally host it under the same origin as the Sender API or enable CORS.
Convert into Angular components easily.
6) Security & Production Notes (short)
Always store subscriber secrets securely (KeyVault). Do not log secrets.
Use HTTPS and validate hostnames for callbackUrl to avoid SSRF.
Enforce per-subscriber rate limits and global rate limits.
Make delivery worker idempotent and safe to run in multiple instances (use DB locking or lease). Our sample uses DB row status checks; for scale implement transactional "claim" of attempts.
Consider using cloud storage or SQS for huge payloads; send pointers to payloads instead of raw bytes.
For very high throughput use event streaming (Kafka) and horizontally scaled workers.
Monitor metrics (success/fail rates, latency) and alert on anomalies.