De valley of webhooks: wat je bouwt als delivery at-least-once is

Een populaire Hacker News-post noemt webhooks een lokaal optimum. Hier is het antwoord uit de praktijk in .NET: verifieer de ruwe body, dedupliceer op de event-id, reconcilieer luid, en wanneer je beter een pull-log levert.

Jean-Pierre Broeders

Freelance .NET Developer

6 augustus 202614 min. leestijd
De valley of webhooks: wat je bouwt als delivery at-least-once is

Er staat deze week een post hoog op Hacker News met een titel die bleef hangen: "The Valley of Webhooks." De auteur heeft drie keer een op webhooks gebaseerde datasynchronisatie gebouwd, en het stuk maakt rustig en zorgvuldig het punt dat webhooks een lokaal optimum zijn. Een comfortabele kuil op de kaart waar je maar moeilijk uit klimt, omdat elk probleem dat ze veroorzaken al een workaround heeft, en elke workaround al een leverancier.

Ik heb precies die code geschreven. De halve industrie ook. Ik denk dat de diagnose klopt, en juist daarom loont het om scherp te zijn over wat je ermee doet op een dinsdag, als het ticket zegt dat "onze kopie van het abonnement van de klant weer niet klopt."

De diagnose klopt

De kern van het betoog splitst webhooks in twee taken. Taak een is het triggeren van een neveneffect: een bon versturen, een CI-build starten, iets naar Slack posten. Taak twee is de data van een provider repliceren naar je eigen database zodat je er lokaal op kunt queryen. Webhooks zijn geboren voor taak een. De meesten van ons pakken ze voor taak twee, en taak twee vraagt precies de eigenschappen die webhooks missen: volgorde, volledigheid, een manier om vanaf de huidige stand te bootstrappen, en een manier om te verifiëren dat je alles hebt.

De auteur is helder over waar dit eindigt. Je schrijft een nachtelijke reconciliation-cron die alles opnieuw ophaalt en je kopie overschrijft. In de woorden van de post is die cron "a written confession. It says: I do not trust the copy I built, and I have no way to know when it's wrong, so I will re-derive it from scratch every night, forever." Het voorbeeld waar ik van kromp: een klant had maanden eerder opgezegd en de database zei nog steeds active, want een customer.subscription.deleted "had evaporated between Stripe and us, and nothing anywhere was capable of noticing."

Het gemiste event is nog te vergeven. Wat je bang zou moeten maken is dat niets het merkte.

De meesten van ons zitten aan de ontvangende kant

Hier wijk ik af van hoe het artikel meestal wordt gedeeld. De voorgestelde oplossing, die de auteur schetst als SCROLL (Synchronized Change Replication Over Line Logs), vraagt de provider om een geordende, cursor-gebaseerde log bloot te stellen waar jij uit trekt. Het is een goed ontwerp. Ik bouw de provider-kant verderop. Maar jij mag de API van Stripe dit kwartaal niet herontwerpen. Op de dag dat het ticket binnenkomt ben je de consumer, en je taak is een ontvanger bouwen die correct blijft bovenop delivery die je niet kunt veranderen.

Dus eerst het consumer-playbook, in .NET, zoals ik het echt schrijf.

Een ontvanger die at-least-once overleeft

Vier regels. Geen enkele slim. Allemaal expres saai.

Verifieer de signature over de ruwe body

Controleer de signature over de exacte bytes, voordat je iets deserialiseert, met een constant-time vergelijking. Een timing-veilige vergelijking is belangrijk omdat een naïeve string-vergelijking lekt hoeveel voorste bytes matchten.

using System.Security.Cryptography;
using System.Text;

// Header-vorm "sha256=<hex>", bijvoorbeeld GitHubs X-Hub-Signature-256.
static bool HasValidSignature(byte[] rawBody, string secret, string? header)
{
    const string prefix = "sha256=";
    if (header is null || !header.StartsWith(prefix, StringComparison.Ordinal))
        return false;

    byte[] provided;
    try { provided = Convert.FromHexString(header.AsSpan(prefix.Length)); }
    catch (FormatException) { return false; }

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var computed = hmac.ComputeHash(rawBody);

    // Geeft false bij elk lengteverschil, verder in constante tijd.
    return CryptographicOperations.FixedTimeEquals(computed, provided);
}

Het woord "ruw" draagt hier het gewicht. Bind je het request aan een model en serialiseer je het opnieuw om de signature te checken, dan val je om op de dag dat de provider een veld toevoegt of wat whitespace verandert. Lees de bytes van de lijn en hash die.

app.MapPost("/webhooks/billing", async (HttpRequest req, Inbox inbox) =>
{
    using var buffer = new MemoryStream();
    await req.Body.CopyToAsync(buffer);
    var body = buffer.ToArray();

    var header = req.Headers["X-Signature-256"].ToString();
    if (!HasValidSignature(body, BillingSecret, header))
        return Results.Unauthorized();

    var evt = JsonSerializer.Deserialize<ProviderEvent>(body)!;
    await inbox.StoreAsync(evt.Id, evt.Type, body);

    return Results.Ok();
});

Bevestig in milliseconden, verwerk buiten de lijn

Let op wat die handler niet doet: hij draait geen businesslogica. Hij schrijft het event naar een inbox-tabel en geeft 200 terug. Doe je het echte werk inline, dan wordt een trage downstream-call een timeout, de provider retryt, en nu verwerk je hetzelfde event twee keer terwijl de eerste ronde nog loopt. Opslaan, bevestigen, en dan een achtergrondworker de inbox laten leegtrekken. De retry-timer van de provider is jouw probleem niet meer zodra de rij veilig op schijf staat.

Ga ervan uit dat elk event minstens twee keer komt

At-least-once is een belofte, en de belofte is duplicaten. Dus de schrijfactie moet idempotent zijn, met de eigen event-id van de provider als sleutel. Laat de database het afdwingen met een unieke index en behandel de botsing als succes.

public sealed class InboxEvent
{
    public long Id { get; set; }
    public required string EventId { get; init; }   // id van de provider, dit is de dedup-sleutel
    public required string Type { get; init; }
    public required byte[] Payload { get; init; }
    public DateTimeOffset ReceivedAt { get; init; } = DateTimeOffset.UtcNow;
    public bool Processed { get; set; }
}

// modelBuilder.Entity<InboxEvent>().HasIndex(e => e.EventId).IsUnique();

public sealed class Inbox(AppDb db)
{
    // Geeft true als het event nieuw was, false als we het al hadden opgeslagen.
    public async Task<bool> StoreAsync(string eventId, string type, byte[] payload)
    {
        db.Add(new InboxEvent { EventId = eventId, Type = type, Payload = payload });
        try
        {
            await db.SaveChangesAsync();
            return true;
        }
        catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
        {
            db.ChangeTracker.Clear();   // gooi de mislukte insert weg zodat de context bruikbaar blijft
            return false;               // 23505 is unique_violation: dit event kennen we al
        }
    }
}

Op SQL Server heet de guard foutnummer 2627 of 2601; op Postgres is het SQLSTATE 23505. Zelfde idee, andere constante.

Vertrouw de volgorde niet

Events komen in de verkeerde volgorde binnen. Een membership.created kan vóór de bijbehorende user.created landen. Neemt je verwerking aan dat de parent bestaat, dan gooit hij een exception, retryt de provider, en heb je geluk of niet. Twee eerlijke opties. Verwerk elk event als een upsert met de resource-id als sleutel, zodat een latere stand een eerdere gewoon overschrijft. Of, als je de volgorde echt nodig hebt, buffer het event en wacht tot zijn voorwaarde binnen is voordat je verdergaat. Wat je nooit doet is aannemen dat de volgorde klopt omdat hij in de test klopte.

Reconciliation mag, als hij maar luid is

Het artikel schildert de nachtelijke reconcile af als een bekentenis. Ik lees het anders. Reconciliation is een prima vangnet. Een stille is de echte zonde. Als je cron elke nacht stilletjes active overschrijft met canceled en het niemand vertelt, heb je een data-integriteitsbug verstopt achter een geplande taak, en je hoort het van een klant in plaats van van je dashboard.

Dus reconcilieer door de eventlijst van de provider op te halen, hem door dezelfde idempotente inbox te halen, en de drift te tellen.

public async Task<int> ReconcileAsync(CancellationToken ct)
{
    var cursor = await _state.GetLastEventIdAsync();
    var options = new EventListOptions { Limit = 100 };
    if (cursor is not null) options.StartingAfter = cursor;

    var events = new EventService();
    var repaired = 0;

    await foreach (var evt in events.ListAutoPagingAsync(options, cancellationToken: ct))
    {
        var isNew = await _inbox.StoreAsync(evt.Id, evt.Type, Encoding.UTF8.GetBytes(evt.ToJson()));
        if (isNew) repaired++;                 // een gat dat de webhook nooit afleverde
        await _state.SaveLastEventIdAsync(evt.Id);
    }

    if (repaired > 0)
        _logger.LogWarning("Reconcile heelde {Count} ontbrekende events", repaired);

    return repaired;
}

Die repaired-teller is het hele punt. Hang er een alert aan. Zit reconciliation routineus tientallen events te repareren, dan heeft je webhook-pad een echt probleem en heb je nu het getal om het te bewijzen. Repareert hij een maand lang nul, dan mag je je live-pad iets meer vertrouwen. En omdat het een geplande taak is die stil, belangrijk werk doet, monitor de taak zelf. Een reconcile die ongemerkt stopt met draaien is precies hoe je weer bij een klant belandt die zegt dat zijn abonnement niet klopt.

Stripe helpt hier bewust. Het bewaart dertig dagen aan events en stelt /v1/events beschikbaar voor precies dit soort listing. WorkOS levert een cursor-gepagineerde Events API om dezelfde reden. De nooduitgang bestaat al in de platforms die erover nadachten.

Als je zelf de API bezit, lever een log

Nu de provider-kant, want soms ben jij degene die de webhooks verstuurt. Hier verdient het SCROLL-argument zijn geld. Laat je consumers niet alles hierboven bouwen. Geef ze een geordende log om uit te trekken.

De truc die het correct maakt is de transactional outbox. Schrijf het change-record in dezelfde databasetransactie als de entiteit die het beschrijft. Committen ze samen, dan kan de log het nooit oneens zijn met de data.

public async Task CancelAsync(string subscriptionId)
{
    var sub = await _db.Subscriptions.SingleAsync(s => s.Id == subscriptionId);
    sub.Status = "canceled";

    _db.Changes.Add(new Change
    {
        Resource = "subscription",
        ResourceId = sub.Id,
        Type = "deleted",
        Deleted = true,                        // een tombstone: de delete blijft zichtbaar
        Data = JsonSerializer.Serialize(sub)
    });

    // Eén SaveChanges, één transactie: de log en de rij committen samen.
    await _db.SaveChangesAsync();
}

Change.Seq is een monotone, door de database toegekende sleutel. Dat is je cursor. Het endpoint is één GET die newline-gescheiden JSON streamt vanaf een gegeven cursor vooruit.

app.MapGet("/v1/changes", async (long? since, int? limit, AppDb db, HttpResponse res) =>
{
    var after = since ?? 0;                     // geen cursor betekent bootstrappen vanaf nul
    var take = Math.Clamp(limit ?? 100, 1, 1000);

    var page = await db.Changes
        .Where(c => c.Seq > after)
        .OrderBy(c => c.Seq)
        .Take(take)
        .ToListAsync();

    res.ContentType = "application/x-ndjson";
    await using var writer = new StreamWriter(res.Body, Encoding.UTF8);
    foreach (var c in page)
        await writer.WriteLineAsync(JsonSerializer.Serialize(new
        {
            seq = c.Seq, resource = c.Resource, id = c.ResourceId,
            type = c.Type, deleted = c.Deleted, data = c.Data
        }));
});

Kijk wat de consumer gratis krijgt. Volgorde, want de rijen komen op Seq naar buiten. Bootstrap, want since=0 leest vanaf het begin der tijden. Deletes, want een tombstone is zelf een event; de rij verdwijnt niet stilletjes. Hervatbaarheid, want ze bewaren de laatste seq en vragen daarvandaan opnieuw. Geen endpoint om te registreren, geen signing-secret om te roteren, geen publieke URL die een laptop achter een NAT moet bereiken. Elke wijziging komt binnen over een verbinding die zij openden, met de API-key die ze al hebben.

Wanneer webhooks nog steeds het juiste zijn

Niets hiervan betekent dat webhooks fout zijn. Voor taak een zijn ze prima. Een "betaling gelukt, stuur de mail"-trigger, waar een zeldzame misser door een retry wordt opgevangen, heeft geen log en cursor en outbox-tabel nodig. Push-latency is een echt voordeel: het event is er binnen een seconde. De faalmodus van "we stuurden de welkomstmail twee keer" is een schouderophalen, geen incident. Hou webhooks voor de neveneffecten waarvoor ze zijn ontworpen. Grijp naar een log op het moment dat de correctheid van een gerepliceerde dataset op het spel staat.

Veelgestelde vragen over webhooks in .NET

Waarom moet ik de webhook-signature over de ruwe request-body verifiëren? Omdat de signature over de exacte bytes wordt berekend die de provider stuurde, en elke herserialisatie die kan veranderen. Bind je het request aan een model en serialiseer je het terug, dan laat een nieuw veld, een andere volgorde van properties of een verschil in whitespace een geldige payload alsnog falen op verificatie. Lees de ruwe bytes van de request-stream, hash die, en vergelijk met een constant-time functie als CryptographicOperations.FixedTimeEquals zodat je niet lekt hoeveel bytes er matchten.

Hoe voorkom ik dat ik dezelfde webhook twee keer verwerk? Behandel de event-id van de provider als unieke sleutel in een inbox-tabel en laat de database duplicaten weigeren. Voeg de rij toe en behandel de unique-violation-exceptie als een geslaagde no-op, want at-least-once delivery garandeert dat je herhalingen ziet. Zo blijft de handler idempotent zonder distributed lock of een extra rondje om eerst het bestaan te checken, en overleeft hij twee deliveries die op hetzelfde moment binnenkomen.

Moet het webhook-endpoint het echte werk inline doen? Nee. Sla het event op, geef binnen milliseconden 200 terug, en verwerk het in een achtergrondworker die de inbox leegtrekt. Draai je businesslogica inline, dan veroorzaakt een trage dependency een timeout, retryt de provider, en verwerk je hetzelfde event gelijktijdig. Snel bevestigen plus verwerking buiten de lijn houdt je van de retry-timer van de provider af en houdt je endpoint responsief onder een piek.

Heb ik nog een reconciliation-taak nodig als mijn webhooks werken? Ja, en hij moet luid zijn. Haal de eventlijst van de provider op, voer hem door dezelfde idempotente inbox, en tel hoeveel events hij moest helen. Alarmeer zodra dat getal boven nul komt, en monitor de taak zodat een stille storing van de reconcile zelf geen drift kan verbergen. Reconciliation is een vangnet, en een stil vangnet is het ding dat je uiteindelijk bijt.

Wat is het verschil tussen een webhook en een pull-gebaseerde eventlog? Een webhook is de provider die een event naar jouw URL pusht, met at-least-once delivery, geen garantie op volgorde, en geen manier om de historie te herspelen na het retentievenster. Een pull-gebaseerde log is dat jij een geordende, cursor-gebaseerde stroom leest over een verbinding die jij opende, wat je volgorde, bootstrap vanaf het begin, tombstones voor deletes en hervatbaarheid na downtime geeft. Logs passen bij datareplicatie, webhooks bij losse neveneffecten.

Verder lezen


Conclusie: Behandel "The Valley of Webhooks" als een ontwerp-checklist. Geen migratieplan. Als consumer maak je je ontvanger saai en je reconciliation luid. Als provider geef je mensen een log om uit te trekken.

Bronnen: The Valley of Webhooks · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op