To prevent repetitive agent activities before they start, use delivery IDs and persistent execution state.
A webhook is not evidence that your program has never seen the event before; rather, it is a notification that something occurred. Processing a repeated notice in a.NET endpoint that initiates an agent workflow might be expensive since the handler can create two pull requests, enqueue work twice, or repeat an external write. That issue is not always resolved by serializing access to a chat. The duplicate may just run second.
This guide designs the decision boundary that comes before expensive work. You will distinguish delivery identity from logical event identity, sketch a C# ingress handler backed by a durable inbox, and use a small concurrency test matrix to challenge its assumptions. The code is illustrative and has not been compiled or executed.
Environment and prerequisites
Audience: C# backend engineers comfortable with ASP.NET Core endpoints, asynchronous processing, and relational database constraints. The design assumes a .NET 8-style minimal API or equivalent controller, an authenticated GitHub webhook, a persistent store with a unique-key constraint, and a durable worker queue or transactional outbox. The concepts do not depend on a particular EF Core version or database provider; actual exception handling and transaction semantics do.
The incident that makes the boundary visible
In a GitHub issue opened June 11, 2026, the Archon project received a report of a duplicate webhook delivery initiating a second workflow. The report describes a conversation lock that serialized duplicate messages without recognizing them as the same trigger. The second message waited and could then run after the first. This is a historical, closed issue, not evidence that Archon still has the problem in October 2026. Source: Archon issue #1951.
The transferable lesson is narrower than a claim about that project’s current behavior: a lock answers “who runs now?” but idempotency answers “should this logical work run again?” Those questions need different state and different tests. A handler can pass all concurrency-lock tests and still authorize a duplicate side effect.
Start with two identities, not one hash
For GitHub webhook redelivery, the X-GitHub-Delivery header identifies a delivery GUID. GitHub documents that its value stays the same when that delivery is redelivered. Store it with a provider and webhook-subscription scope so that an identical delivery can be recognized. Reject an absent identifier instead of silently inventing one. Verify the webhook signature using the raw request body before trusting either the header or payload. Source: GitHub webhook best practices.
A separate question arises when two independent subscriptions deliver notifications about the same underlying activity. They may arrive with different delivery IDs. Delivery-ID deduplication alone cannot prove that they represent one business operation. Where the use case demands it, derive a logical event key from stable, documented domain identifiers, such as repository identity, comment identity and action, then define which changes legitimately start another job.
Do not use a raw JSON hash as an unquestioned event identity. Header variations, harmless payload changes, and legitimate edit or replay semantics can make byte equality too strict or too loose. Keep the delivery ledger for transport replay, and use a separately designed business-operation key for semantic deduplication. This distinction also prevents a legitimate later edit from being discarded merely because it concerns the same issue.
Persist the decision before dispatch
A safe ingest boundary is not “SELECT then INSERT.” Two requests can both observe no row and both dispatch. Use a unique database constraint on (provider, subscription, delivery_id), with an atomic insert-or-conflict path. If deduplicating logical actions, give the business-operation key its own uniqueness rule and retention policy. The database, not a process-local lock, must arbitrate concurrent claims.
Make the inbox record and the instruction to start work durable together. In one transaction, record the accepted delivery and an outbox command for a worker. Commit, then acknowledge the webhook. A crash after committing but before sending the HTTP response can cause a redelivery; that redelivery should find the same persisted record. A crash before committing leaves no acknowledged work. Do not call the agent before the durable boundary succeeds.
The worker then owns a state machine such as RECEIVED → RUNNING → COMPLETED, with FAILED_RETRYABLE, FAILED_TERMINAL and REVIEW_REQUIRED as explicit branches. A mere RUNNING marker is not proof of success. Recover abandoned claims using an appropriately fenced lease or claim token so an old worker cannot finish after a replacement worker takes over. Downstream operations, especially external writes, may need their own idempotency keys or reconciliation checks.
Illustrative C# ingress flow
The following ASP.NET Core-shaped example shows only the ingress decision. The verifier, inbox, hookId and domain types are application-provided abstractions. RecordAndEnqueueAsync must atomically save an inbox record and an outbox instruction under a database uniqueness constraint. Do not copy this into production without supplying those implementations, validating the raw-body signature path, and testing the database behavior.
public async Task<IResult> ReceiveAsync(HttpRequest request, CancellationToken ct)
{
if (!await verifier.IsValidAsync(request, ct))
return Results.Unauthorized();
var deliveryId = request.Headers["X-GitHub-Delivery"].ToString();
if (string.IsNullOrWhiteSpace(deliveryId))
return Results.BadRequest("Missing delivery ID");
var key = new DeliveryKey("github", hookId, deliveryId);
var outcome = await inbox.RecordAndEnqueueAsync(key, request, ct);
return outcome switch
{
IngestOutcome.Inserted => Results.Accepted(),
IngestOutcome.AlreadyRecorded => Results.Ok(),
_ => Results.StatusCode(503)
};
}
New work returns a success response only after the transaction commits. An already recorded delivery is acknowledged without issuing another outbox command. A transient persistence failure returns a retryable failure rather than pretending work was safely queued. The precise 2xx status is an application decision; receiving a 2xx does not mean the agent has finished. GitHub recommends responding promptly and processing longer tasks asynchronously.
This snippet intentionally stops before worker execution. A complete implementation needs payload bounds and event-type checks, protected logging, transactional outbox delivery, retries, worker claims, monitoring, and careful cancellation behavior. A uniqueness violation on insert may surface as a database-provider-specific exception, not an EF Core DbUpdateConcurrencyException; catch and classify the actual provider error before treating it as a harmless duplicate. Source: EF Core concurrency guidance.
A hypothetical two-delivery walk-through
Suppose request A and request B contain the same signed delivery ID. Both reach the API simultaneously. One transaction inserts the inbox key and its outbox command; the other’s attempt hits that key’s uniqueness rule. Only one command is committed. If the first response is lost and a redelivery arrives later, the existing row causes acknowledgment without redispatch. This is a proposed behavior to implement and verify, not a measured outcome from this draft.
Now suppose two different hook subscriptions fire for the same comment. Their delivery keys differ, so both transport records can be valid. Whether one or two agent jobs should exist is a separate business policy. A second unique logical-operation key can suppress the redundant job if and only if that event equivalence has been defined correctly. When event identity is ambiguous, route to a review or reconciliation state instead of silently dropping work.
Concurrency and failure test matrix
A useful acceptance test asserts persisted records and external effects, not merely HTTP status codes. Run each case against the real database and queue implementation; do not infer success from a unit-test mock. This table is a proposed, unexecuted test plan.
| Test case | Stimulus | Expected assertion |
| Concurrent replay | Two signed requests, same delivery ID, arrive together | One inbox row and one outbox command; no double dispatch |
| Response lost | Committed ingest is redelivered after its HTTP reply is lost | Existing key is acknowledged; no new work item |
| Commit aborted | Fail before inbox/outbox transaction commits | No orphan job; retry can safely create the work |
| Worker interruption | External call succeeds, worker crashes before completion state | Retry reconciles or uses downstream idempotency; no blind second write |
| Two subscriptions | Different delivery IDs represent one comment action | Two receipt records; one business job only if semantic key policy says so |
| Legitimate edit | Same comment changes with a new permitted action | New authorized operation proceeds; no false suppression |
Limits: idempotent ingress is not exactly-once execution
An inbox prevents one class of duplicate dispatch. It does not make a flaky downstream API atomic with your database. The worker can finish an external write and crash before it records completion. A retry may see the job as unfinished. Use provider-supported idempotency keys for the external call when available, or persist an operation identity and reconcile actual remote state before replaying the action.
Data retention is also a correctness rule. Delete the dedup record too early and an older redelivery could look new. Keep a clear retention window derived from delivery history, business expectations, privacy constraints, and storage cost. Distinguish a new action on an edited comment from a transport replay of the old action. Do not turn every repeated-looking message into a permanent suppression.
Security stays in front of idempotency. Validate signatures against the exact raw payload, require expected event/action types, enforce reasonable payload limits, and avoid writing secrets or full customer payloads to an unrestricted ledger. Record safe identifiers, receipt times, transitions, retries, claim tokens and stable correlation IDs sufficient for audit and recovery.
Conclusion: define the invariant that matters
For the selected webhook trigger and its chosen business-operation identity, acceptance should create no more than one durable dispatch instruction. That is a checkable invariant. Write a test that sends the same delivery concurrently, kills the worker at controlled boundaries, retries the delivery, and counts both outbox commands and actual external effects. Success requires correct duplicate handling, recoverability, and no lost legitimate work.
Best ASP.NET Core 11.0 Hosting
The feature and reliability are the most important things when choosing a good ASP.NET Core 11.0 hosting. HostForLIFE is the leading provider of Windows hosting and affordable ASP.NET Core , their servers are optimized for PHP web applications such as the latest ASP.NET Core 11.0 version. The performance and the uptime of the ASP.NET Core hosting service are excellent, and the features of the web hosting plan are even greater than what many hosting providers ask you to pay for. At HostForLIFE.eu, customers can also experience fast ASP.NET Core hosting. The company invested a lot of money to ensure the best and fastest performance of the datacenters, servers, network and other facilities. Its data centers are equipped with top equipment like cooling system, fire detection, high-speed Internet connection, and so on. That is why HostForLIFE.eu guarantees 99.9% uptime for ASP.NET Core . And the engineers do regular maintenance and monitoring works to assure its ASP.NET Core hosting are security and always up.

