What invariants must survive when webhook callbacks outlive the session that registered them?
**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session registers webhook callbacks with external services (payment processors, message queues, or event streams), and the session terminates before the callback fires, requiring a successor to receive and handle the callback. Does not apply to strictly synchronous request-response integrations where no callback outlives the request.
Core Distinction
The separation between **callback registration** (the session established the webhook subscription) and **callback handling ownership** (which session is authorized to receive and process the callback when it fires). Relying on registration creates the **Orphaned Callback Hazard**: the session terminates, the webhook fires, and no session is authorized to receive it (or the wrong session receives it), causing lost events, duplicate handling, or security issues.
What Was Observed
In webhook-integrated agent architectures, failing to preserve callback ownership across session termination produces three primary failure modes:
- **Lost Callbacks:** The session registers a webhook, terminates, and the callback fires to a dead endpoint. The event is lost, causing missed payments or notifications.
- **Orphaned Effects:** The webhook fires to an endpoint that is now served by a different session (e.g., after load balancer rerouting). The successor does not know what to do with the callback and either drops it or mishandles it.
- **Duplicate Handling:** Both the original session (before it fully terminates) and the successor receive the same webhook and both handle it, causing double-execution.
Recorded Elements
- **`callback_ownership_receipt`:** A cryptographic proof binding the webhook subscription to the session that registered it, and to the designated successor if the session terminates.
- **`callback_routing_manifest`:** The exact routing rules (endpoint, headers, authentication) for each webhook, allowing successors to configure their receivers consistently.
- **`callback_idempotency_key_receipt`:** A key embedded in the webhook registration (if supported) that allows the successor to dedupe callbacks that overlap between the terminated session and the successor.
- **`callback_acknowledgment_protocol_receipt`:** For webhooks requiring acknowledgment, the exact protocol the successor must use, ensuring the external service does not retry indefinitely.
Condition of Validity
* **Applies when:** The session registers webhooks that can fire after session termination, and the callbacks have material consequences. * **Does not apply when:** Webhooks fire strictly within the session's lifetime (e.g., synchronous callback in the same request), or webhooks are strictly informational with no action required.
Decision Boundary
- **Does the session register webhooks?** If no, standard webhook handling applies. If yes, proceed.
- **Is the `callback_ownership_receipt` recorded before termination?** If no, the successor has no authorization to receive callbacks. The session must persist this before terminating.
- **Does the `callback_routing_manifest` define the full routing for successors?** If no, callbacks may route incorrectly. The session must export the routing configuration.
- **Conversely, when can a session register webhooks without ownership tracking?** Only when the webhooks fire strictly within the session's lifetime (guaranteed by the external service), or when the callbacks are strictly fire-and-forget with no downstream consequence.
Operational Effect
- **Orphaned Callback Prevention:** Ensures successors are authorized and configured to receive callbacks.
- **Duplicate Handling:** Uses idempotency keys to prevent double-execution when both sessions overlap.
- **External Service Compatibility:** Provides the acknowledgment protocol, preventing the external service from retrying indefinitely.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json
class WebhookVerdict(Enum):
NO_WEBHOOK = auto()
OWNERSHIP_VERIFIED = auto()
ORPHANED_CALLBACK = auto()
ROUTING_UNDEFINED = auto()
@dataclass(frozen=True)
class WebhookRegistration:
webhook_id: str
registering_session: str
designated_successor: str | None
callback_ownership_receipt: str | None
routing_manifest_present: bool
@dataclass(frozen=True)
class WebhookContext:
current_session: str
external_service_retries_bounded: bool
@dataclass(frozen=True)
class WebhookManifest:
verdict: WebhookVerdict
directive: str
manifest_digest: str
class WebhookOwnershipGuard:
@staticmethod
def _hash(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@classmethod
def evaluate_webhook(
cls, reg: WebhookRegistration, ctx: WebhookContext
) -> WebhookManifest:
# Gate 1: No webhook
if not reg.webhook_id:
digest = cls._hash(json.dumps({
"webhook": "NONE"
}, sort_keys=True))
return WebhookManifest(
verdict=WebhookVerdict.NO_WEBHOOK,
directive="PROCEED: No webhook registered.",
manifest_digest=digest
)
# Gate 2: Ownership receipt
if not reg.callback_ownership_receipt:
return WebhookManifest(
verdict=WebhookVerdict.ORPHANED_CALLBACK,
directive=f"HALT: Webhook {reg.webhook_id} lacks ownership receipt.",
manifest_digest=cls._hash(f"NO_OWNERSHIP:{reg.webhook_id}")
)
# Gate 3: Routing
if not reg.routing_manifest_present:
return WebhookManifest(
verdict=WebhookVerdict.ROUTING_UNDEFINED,
directive=f"HALT: Webhook {reg.webhook_id} has no routing manifest for successor.",
manifest_digest=cls._hash(f"NO_ROUTING:{reg.webhook_id}")
)
# Gate 4: Successor authorization
if ctx.current_session != reg.registering_session and ctx.current_session != reg.designated_successor:
return WebhookManifest(
verdict=WebhookVerdict.ORPHANED_CALLBACK,
directive=f"HALT: Session {ctx.current_session} not authorized to receive {reg.webhook_id}.",
manifest_digest=cls._hash(f"UNAUTH:{ctx.current_session}")
)
# Gate 5: Verified
digest = cls._hash(json.dumps({
"webhook": reg.webhook_id,
"origin_session": reg.registering_session,
"current_session": ctx.current_session,
"successor": reg.designated_successor or "NONE",
"ownership": reg.callback_ownership_receipt
}, sort_keys=True))
return WebhookManifest(
verdict=WebhookVerdict.OWNERSHIP_VERIFIED,
directive=f"PROCEED: Webhook {reg.webhook_id} ownership verified for {ctx.current_session}.",
manifest_digest=digest
)