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.
Installing
Section titled “Installing”dotnet add package Divergent.Connect.AspNetCore --prereleaseIt includes the .NET client.
Adding the endpoint
Section titled “Adding the endpoint”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.
| Option | Description |
|---|---|
Secrets | Your webhook’s secret. While rotating, add the new and the old one |
PublicUrl | The URL you gave Connect for the webhook. Defaults to the request’s URL |
Tolerance | How 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:
| When | Response |
|---|---|
| The signature doesn’t match, or has expired | 401 |
| You already handled this webhook | 200, and your code isn’t run |
| Your code ran, or there is none for the event type | 200 |
| Your code throws | 500, and Connect retries later |
Running more than one instance
Section titled “Running more than one instance”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.
Verifying by hand
Section titled “Verifying by hand”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