DEV Community

Philip Stayetski
Philip Stayetski

Posted on

Why Webhook Retry Logic Keeps Failing You — and What Replaces It When Agents Have Addresses

Webhooks are the universal glue of modern systems. When an event happens in Service A, it fires an HTTP POST to Service B. Simple enough — until B is down, slow, or behind NAT. That's when the retry machinery kicks in: exponential backoff, at-least-once delivery, idempotency keys, dead letter queues. Senders build elaborate recovery pipelines, and receivers sign up for the silent contract of handling duplicates and late arrivals.

The assumption everyone makes is that the webhook is the only option — you push an event and hope the receiver catches it. But that assumption gets quietly baked into weeks of code no one wants to maintain.

What Webhook Delivery Guarantees Actually Look Like

A typical webhook sender publishes a retry policy that sounds reassuring on paper:

— Attempt 1: immediate

— Attempt 2: 10 seconds later

— Attempt 3: 1 minute later

— Attempt 4: 10 minutes later

— ...exponential backoff up to N retries

— Dead letter queue if all fail

That's at-least-once delivery in practice. Every retry is a race: the receiver's endpoint could be temporarily unreachable, a deployment could be in progress, or the DNS record might not have propagated. The sender can't tell the difference between a transient blip and a permanent failure, so it retries and retries.

The Hidden Complexity

That retry contract pushes four problems onto the receiver:

Idempotency. Every event comes with a unique idempotency key. The receiver must track every key it's seen and silently discard duplicates. This means a database table, an expiration policy, and a code path that can't accidentally process the same payment or webhook twice.

Ordering is a lie. Webhook retries don't guarantee ordering. Attempt 2 of event A might arrive after attempt 1 of event B if B was immediately available. Receivers who assumed events arrive in sequence get subtle, months-later bugs.

Dead letter queues need monitoring. Events that exhaust all retries land in a dead letter queue. Someone has to check that queue, replay events, and handle the ones that genuinely can't succeed. This is a side job, not a fire-and-forget system.

Backpressure is invisible. If the receiver is processing slowly, the sender's retries keep piling on. The receiver has no way to say "wait, I'm catching up." Each retry adds pressure to an already strained system.

None of this is visible in the initial integration. It surfaces in production, in the small hours, when a webhook storm hits and the idempotency table fills up faster than the cleanup job runs.

The Root Assumption

Every one of those problems traces back to the same root: the webhook receiver is a passive endpoint. It sits on a URL and hopes the sender can reach it. The sender controls timing, retries, and ordering; the receiver only responds to what arrives.

This made sense in a world where servers had static IPs and firewalls were the exception. But agents running on laptops, CI runners, containers without load balancers, and edge devices — they don't have a permanent address. They're unreachable by definition. The sender's retry logic is the consequence, not the solution.

What Changes When the Peer Is Addressable

Flip the model: instead of the sender pushing to an endpoint it hopes exists, the receiver is directly reachable on the network. It has a permanent address that survives restarts, network changes, and cloud migrations. The sender doesn't need exponential backoff — it needs a message to a known address.

A persistent tunnel overlay turns every agent into a first-class peer. The daemon initiates an outbound connection (works behind any NAT), registers a stable address, and maintains an encrypted tunnel. Messages arrive as typed payloads to the agent's handler — no HTTP endpoint, no webhook configuration, no retry policy to negotiate.

from pilotprotocol import PilotNode

node = PilotNode()

@node.on_message
async def handle(sender, payload):
    print(f"Event from {sender}: {payload}")
    processed = process(payload)
    if processed:
        await node.send(sender, {"ack": True})

node.connect()
Enter fullscreen mode Exit fullscreen mode

No idempotency keys. No dead letter queue. The transport — not the application — handles delivery. If the receiver is offline, the message is held by the network and delivered when the receiver reconnects. The sender sends once and moves on.

This isn't abstract. The daemon that makes this possible is one command:

curl -fsSL https://pilotprotocol.network/install.sh | sh
pilotctl daemon start  # agent gets a permanent address
Enter fullscreen mode Exit fullscreen mode

What You Keep vs What Changes

The webhook model is good at one thing: letting a sender fire-and-forget events. Retry policies attempt to make fire-and-forget reliable, but they treat the symptom — unreachable endpoints — rather than the cause.

With direct addressability:

  • You keep event-driven architecture. Events still trigger actions. The difference is the transport is reliable by default.
  • You keep asynchronous communication. The sender doesn't block. It dispatches and continues.
  • You lose idempotency handling. No duplicate detection code. No key-expiration jobs.
  • You lose exponential backoff configuration. No more "what's the right retry interval for this partner's SLA?"
  • You lose dead letter queue monitoring. Failed messages don't vanish into a queue; they fail cleanly on an open connection you can inspect.

Where to Draw the Line

Direct peer addressing isn't a replacement for every HTTP webhook. If you're building a public API that thousands of third parties consume, webhooks are still the standard — no one wants to install a tunnel daemon to receive your events. The receiver is passive by design in that scenario.

But for internal services, agent-to-agent communication, and especially any system where the receiver runs outside a data center — on a laptop, a container without a static IP, a corporate network — the webhook retry contract adds complexity without value. The receiver should be reachable, not patched together with retries.

The question to ask when you're designing your next retry table: "What if the receiver just had an address?" The answer is usually less code, fewer failure modes, and a transport layer that does the work so you don't have to.

Top comments (0)