The handler ran. The response didn't.
A payment team I know spent two days chasing a billing duplicate. Their Stripe webhook handler processed the invoice.paid event: marked the invoice settled, updated the subscription record, sent the customer a receipt. Then it tried to write the 200 response and the database connection pool was full on cleanup. Stripe saw a 500. Stripe retried. The handler ran again.
Their idempotency check caught it, barely. The duplicate ran far enough to query the invoice before the dedup table returned the hit. If the idempotency table lookup had also failed, they would have sent a second receipt and created two audit log entries for a single payment.
The business logic ran. The acknowledgment did not. The sender had no way to know the difference, and the only tool the receiver had to communicate anything was an HTTP status code.
That is not enough. And some delivery platforms are starting to fix it.
What HTTP status codes tell a webhook sender and what they miss
The HTTP status code model works fine for request-response APIs. A 200 means the server handled the request. A 500 means something went wrong and the caller might try again.
Applied to event delivery, it breaks down fast. The sender's retry logic sees a 500 and assumes something transient went wrong. It has zero visibility into whether the handler ran to completion before the response failed, or whether it crashed on line two. Both look identical from the wire.
What the sender actually needs to know is narrower. Did the receiver process this event? If not, should we retry this specific delivery? Should we stop sending to this endpoint at all? A 200 answers the first question. A 410 Gone answers the third. Nothing in the standard HTTP space answers the second one, not for this specific delivery, right now.
The split-brain scenario in practice
The term "split-brain" comes from distributed systems: two nodes each believing they hold the authoritative state. The webhook version is simpler and more common. A receiver processed an event. The sender thinks delivery failed.
The most frequent cause: your handler finishes processing, then throws during response finalization. An uncaught exception in a logging call, a metrics flush that times out, a middleware that runs after your handler returns. You sent a 500. You actually completed.
A nastier variant: your handler processes across multiple writes. First write succeeds. Second fails. You return a 500, which is accurate enough since processing is incomplete. The sender retries. Now the first write runs again and you have partial state from the first run colliding with a fresh execution.
Third case, operational: a deployment restart kills the process mid-response. The handler finished its database writes but the TCP connection dropped before the response flushed. The load balancer logged a 502. The sender sees a connection failure. The event replays.
Idempotency as the standard defense
The standard answer to all three of those is idempotency. Detect when you have seen a delivery before and short-circuit. For Stripe webhooks the event ID is the dedup key. For Standard Webhooks senders the webhook-id header serves the same purpose. Store the key, check before processing, skip if already seen.
This works when the idempotency check is fast and durable. It protects against the duplicate charge, the duplicate receipt, the duplicate subscription update. The idempotency keys post covers correct implementation and what happens when the dedup table itself is under contention.
But idempotency does not fix the retry cost. If a sender retries an event eight times over 24 hours because it got a 500, your handler runs eight times and pays eight idempotency lookups before short-circuiting. At scale that is real compute. And if the idempotency store is temporarily unavailable, the same database overload that caused the original failure, the protection is gone exactly when you need it.
The sender has no way to know that retries are pointless. It just retries.
Abort-message: telling the sender you already processed it
Svix introduced a response header in July 2026 that targets this directly. When a receiver includes webhook-delivery: abort-message in its response, Svix cancels further retries for that specific delivery. The event is marked received and the retry queue moves on.
The use case is the split-brain case: your idempotency check found that this event ID was already processed, or you processed it successfully but are returning a non-200 for some other reason. Include the header, and the sender stops retrying this message.
import type { Request, Response } from 'express'
async function handleStripeWebhook(req: Request, res: Response) {
const eventId = req.headers['webhook-id'] as string
const alreadyProcessed = await idempotencyStore.has(eventId)
if (alreadyProcessed) {
// Tell Svix: stop retrying this one. We have it.
res.setHeader('webhook-delivery', 'abort-message')
res.status(200).json({ status: 'already_processed' })
return
}
try {
await processEvent(req.body)
await idempotencyStore.mark(eventId)
res.status(200).json({ status: 'ok' })
} catch (err) {
// Processing failed for real. Let the sender retry normally.
res.status(500).json({ error: 'processing_failed' })
}
}
One thing worth understanding: abort-message is per-delivery, not per-event. If the same event has multiple in-flight attempts, which happens when a sender retries aggressively, each attempt is a separate delivery. The header cancels retries for the specific attempt that returned it.
Endpoint disable signals
Svix added a second signal in the same July 2026 release: webhook-delivery: disable. Where abort-message targets a single delivery, disable tells the sender to stop delivering to the endpoint entirely.
The gap it fills sits between "this request failed" (5xx) and "this endpoint is permanently gone" (410 Gone). The concrete case: a Zapier trigger is connected to a Zap the user deleted. The URL still responds. There is no business reason to keep delivering. Returning 5xx causes retries. Returning 200 silently discards events. Returning 410 works but requires the receiver to detect the Zapier state and issue the right status code on every incoming request.
With webhook-delivery: disable the response logic is cleaner:
async function handleZapierWebhook(req: Request, res: Response) {
const zapId = req.body.zapId
const zap = await zapStore.get(zapId)
if (!zap || zap.status === 'deleted') {
res.setHeader('webhook-delivery', 'disable')
res.status(410).json({ reason: 'workflow_deleted' })
return
}
// normal processing
}
Pairing the header with a 410 makes the intent visible to both the platform and any HTTP-aware middleware between you and the sender.
The 410 Gone response: portable across platforms
The webhook-delivery header is Svix-specific today. If your sender is Stripe, GitHub, Shopify, or anything else, the header does nothing. The sender will retry based on the HTTP status code alone.
For endpoint disabling, 410 Gone is the portable option. It has defined HTTP semantics: the resource was deliberately removed and callers should stop. Several major webhook senders treat a persistent 410 as a signal to disable the endpoint.
GitHub is documented about this. After multiple consecutive delivery failures, including 410 responses, it stops retrying and marks the webhook inactive. When a GitHub webhook endpoint is no longer valid, a user deactivated their integration, an installation was revoked, returning 410 on the next delivery is the right signal.
The key difference from abort-message: 410 targets the endpoint, not a single message. Use it when you want to stop all future deliveries. For per-message cancellation on a sender that does not support abort-message, idempotency is still what you need.
Designing your handler to signal correctly
The handler structure that uses these signals well is straightforward. Check endpoint state first. Check idempotency second. Process if new. Let the status code and optional header carry the right meaning on each outcome.
async function handleWebhook(req: Request, res: Response) {
const deliveryId = req.headers['webhook-id'] as string
const endpointActive = await endpointRegistry.isActive(req.path)
if (!endpointActive) {
// Endpoint has been decommissioned. Tell the sender to stop.
res.setHeader('webhook-delivery', 'disable')
res.status(410).json({ reason: 'endpoint_decommissioned' })
return
}
const seen = await idempotencyStore.check(deliveryId)
if (seen) {
// Already processed. If the sender is Svix, abort further retries.
// If not Svix, this 200 still stops the retry for this attempt.
res.setHeader('webhook-delivery', 'abort-message')
res.status(200).json({ status: 'duplicate' })
return
}
try {
await processPayload(req.body)
await idempotencyStore.mark(deliveryId)
res.status(200).end()
} catch (err) {
// Genuine failure. The sender should retry.
logger.error({ deliveryId, err }, 'webhook processing failed')
res.status(500).end()
}
}
Idempotency check before business logic means the handler is always safe to re-enter. Endpoint registry check first means decommissioned endpoints immediately signal the sender to stop rather than accumulating a retry backlog while someone figures out the plumbing.
When the sender ignores your signal
Abort-message only works if the sender supports it. Right now that is Svix. GitHub, Stripe, Shopify, Twilio: none of them parse the header. Include it in a response to any of those and they retry based on the status code, same as always.
The practical read: abort-message improves behavior on Svix-hosted delivery without breaking anything elsewhere. Include it defensively on duplicate detections. It costs nothing when the sender does not support it, and it cuts retries when the sender does.
What it cannot do is replace idempotency. On platforms that ignore the header, the retry arrives and your handler has to deal with it correctly regardless. The header is an optimization on top of correct retry handling, not a substitute for it.
If idempotency logic is expensive relative to retry volume, the better lever is usually making the check faster. A Redis lookup with a short TTL and an async database write behind it is orders of magnitude cheaper than re-running business logic on every retry.
Monitoring what retries actually cost you
The split-brain scenario is worth measuring. The metric you want is retry depth distribution: for each successfully delivered event, how many attempts did it take?
Most events should deliver on the first attempt. A tail requiring two or three is normal. Events hitting five or more retries before succeeding point to a structural problem. Your handler is consistently failing after processing, your idempotency check is too slow, or the endpoint is intermittently unhealthy in ways that only surface under load.
Track abort-message usage if your platform exposes it. A spike in aborted messages means your handler is completing but failing to acknowledge. That usually correlates with resource pressure: connection pool exhaustion, OOM events, timeouts on auxiliary writes that run after the main processing path.
Unnecessary retries queue ahead of legitimate ones. They add noise to delivery metrics that makes real failures harder to spot. Getting the signals right keeps the queue honest.
Frequently asked questions
Does abort-message work with any webhook sender, or only Svix?
Only Svix as of August 2026. The webhook-delivery response header is a Svix-specific feature released in their July 2026 changelog. Other senders do not parse it and will retry based on the HTTP status code alone. The header is forward-compatible: include it when you detect a duplicate and it will help if your sender ever adds support.
If I return 200 on a duplicate delivery, does that create any problems? It stops the retry, which is usually what you want for a duplicate. The sender records the delivery as successful. The risk is if your 200 is load-balanced across multiple handler instances and only some of them have committed the idempotency mark. A race during the delivery window can let a second attempt through before the first has persisted. Idempotency stores backed by a strongly consistent database handle this correctly; caches without write-through do not.
When should I return 410 instead of just removing the webhook subscription from the provider side? Removing the subscription from the provider side is always cleaner and should be the primary action. Return 410 as a fallback: if the provider-side removal races with an in-flight delivery, the 410 signals clearly that the endpoint is gone. For integrations where you do not control the provider side, a user configuring their own webhook in your platform, 410 is how you communicate decommissioning to their delivery infrastructure.
What happens if abort-message fires on a legitimate processing failure? The sender stops retrying that delivery. If your handler detected a duplicate incorrectly, a bug in the idempotency check returning false positives, you will silently drop events. Verify your duplicate detection logic before relying on abort-message in production. A false positive on abort-message is worse than an unnecessary retry: retries are recoverable, dropped events usually are not.
Is there a standard across platforms for receiver-to-sender delivery signals? Not yet. The 410 Gone status code is the closest thing to a cross-platform standard for endpoint disabling. Standard Webhooks defines the delivery header format and signature scheme but does not specify receiver-to-sender control signals beyond HTTP status codes. The abort-message pattern exists in Svix today and is not part of any shared specification.