Skip to content

ASP.NET Core Webhooks

Divergent.Connect.AspNetCore adds an endpoint to your app that receives webhooks. It checks the signature, skips webhooks you have already handled and calls your code for each event type.

Terminal window
dotnet add package Divergent.Connect.AspNetCore --prerelease

It includes the .NET client.

using Divergent.Connect.AspNetCore;
using Divergent.Connect.Models;
builder.Services.AddConnectWebhooks(o =>
{
o.Secrets = [builder.Configuration["Connect:WebhookSecret"]!];
o.PublicUrl = "https://yourcompany.com/webhooks/connect";
});
var app = builder.Build();
app.MapConnectWebhooks("/webhooks/connect", hooks =>
{
hooks.On<SmsMessage>(WebhookEventTypes.SmsDelivered, async (evt, services, ct) =>
{
// evt.Data is the SmsMessage
});
hooks.On<EmailMessage>(WebhookEventTypes.EmailBounced, async (evt, services, ct) => { /* ... */ });
hooks.OnAny(async (evt, services, ct) => { /* every event, after its On handlers */ });
});

WebhookEventTypes has a constant for every event type. services is the request’s scope, so you can get your own services from it, such as a DbContext. sms.* events carry an SmsMessage, email.* events an EmailMessage, batch.* events an SmsBatch and suppression.created a Suppression. In OnAny, evt.Data is the raw JSON.

OptionDescription
SecretsYour webhook’s secret. While rotating, add the new and the old one
PublicUrlThe URL you gave Connect for the webhook. Defaults to the request’s URL
ToleranceHow far the signature’s time can be from yours. Defaults to 5 minutes

The endpoint allows anonymous requests, as the signature is what proves a webhook came from us. It replies:

WhenResponse
The signature doesn’t match, or has expired401
You already handled this webhook200, and your code isn’t run
Your code ran, or there is none for the event type200
Your code throws500, and Connect retries later

The endpoint remembers which webhooks it has handled for 48 hours, but only in its own process. If you run more than one instance, store them somewhere shared. With Redis and StackExchange.Redis, implement IWebhookDeduplicator with SET NX:

using Divergent.Connect.Webhooks;
using StackExchange.Redis;
public sealed class RedisWebhookDeduplicator(IConnectionMultiplexer redis) : IWebhookDeduplicator
{
public async ValueTask<WebhookClaim> ClaimAsync(string webhookId, CancellationToken cancellationToken = default)
{
var db = redis.GetDatabase();
var key = "connect:webhook:" + webhookId;
if (!await db.StringSetAsync(key, "1", TimeSpan.FromHours(48), When.NotExists)) return WebhookClaim.Duplicate;
return WebhookClaim.Acquired(async () => await db.KeyDeleteAsync(key));
}
}

Then register it:

builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
builder.Services.AddSingleton<IWebhookDeduplicator, RedisWebhookDeduplicator>();

The key lasts 48 hours, longer than Connect keeps retrying. If your code throws, the key is deleted so the retry is handled.

Outside ASP.NET Core, or to handle the request yourself, use ConnectWebhooks.Verify with the raw body. It returns the event, or throws a WebhookVerificationException saying why it failed.

var evt = ConnectWebhooks.Verify(method, publicUrl, headers, rawBody, [secret]);
var message = evt.DataAs<SmsMessage>();

Verify doesn’t skip repeats. To handle each webhook once, claim its WebhookId with your deduplicator:

await using var claim = await deduplicator.ClaimAsync(evt.WebhookId);
if (claim.IsDuplicate) return; // already handled, reply 200
await HandleAsync(evt);
claim.Complete(); // otherwise the claim is released, so the retry is handled