AnswerQA

How do I make Claude Code react to a webhook without losing events?

Answer

Channels deliver events only while a session is open: at-most-once, no retry. Run Claude in a persistent terminal or background process, design idempotent handlers, and surface failures via a sender that does its own retry, because the channel layer will not.

By Kalle Lamminpää Verified May 7, 2026

Channels deliver events to a running Claude Code session, not to a queue: if the session is closed when an event arrives, the event is gone, and nothing retries if the handler fails mid-turn. Build for at-most-once delivery or you will lose work.

How channels actually deliver

PropertyValue
Delivery semanticsAt-most-once. Events that arrive while the session is closed are not buffered.
RetryNone at the channel layer. Failures are silent unless your sender retries.
Sender gateAllowlist per channel; unknown senders are silently dropped, no error.
Permission promptsPause the session until a human responds, unless the channel declares permission relay or you launch with --dangerously-skip-permissions.
ReplyTwo-way: Claude can reply through the same channel. The reply text appears in the chat or webhook target, not in your terminal output.

The mental model is “Claude is a person at a desk reading messages”. When the desk is occupied, messages arrive. When the desk is empty, messages do not get re-delivered when someone sits back down.

Run a persistent session

For always-on event handling, Claude has to be running. Two shapes:

# In a tmux session, screen, or terminal you do not close
claude --channels plugin:telegram@claude-plugins-official
# As a background process under a process supervisor (systemd, launchd, pm2)
claude --channels plugin:telegram@claude-plugins-official --dangerously-skip-permissions

--dangerously-skip-permissions is only safe in genuinely trusted environments: an isolated VM, a personal laptop, a sandboxed docker container with no production credentials. The first time an unattended Claude approves a git push --force because nobody was watching is the day you regret it.

For permission-relay-capable channels (those that declare the capability in the plugin manifest), you can keep the session interactive: an inbound permission prompt is forwarded to the channel target, you approve from there, the session continues. Verify support by checking the specific plugin’s manifest before relying on it; not every channel declares permission relay.

Design idempotent handlers

Because there is no retry, you cannot rely on “the next event will reconcile state”. Make every handler safe to run zero or more times for the same logical event. The pattern, in three steps:

  1. Read the inbound payload and compute a stable event id from it (e.g., alert.id plus the original timestamp).
  2. Look up that id in your own tracker before acting. If already handled, reply “already on it” and exit.
  3. Otherwise take the action, then write a record keyed by the event id so the next delivery (or accidental retry) is a no-op.

The “look up state first, key the side effect by event id” pattern is the same one you use for any webhook receiver. Channels give you no excuse to skip it: the channel does not provide an event id for you to dedupe on, so derive one from the event content yourself.

Make the sender retry, not the channel

If event delivery matters, the sender has to retry. A Sentry alert that calls a Claude channel via HTTP must keep retrying until it gets a 2xx, regardless of whether the Claude session was open at receive time. The channel does not buffer; the sender owns durability.

Three sender shapes that work:

  • A real queue plus a worker that posts to the channel. Postgres + a pending_events table; a worker dequeues, posts to the channel, marks done on 2xx, retries with backoff on failure.
  • A webhook source with documented retry semantics. SQS-driven Lambda retries on the queue; Stripe webhooks retry with exponential backoff for several days; Slack’s Events API retries failed deliveries. Confirm the specific source’s retry policy before relying on it; “the source will redeliver” is not a universal property of webhooks.
  • A scheduled poll. A cron job that polls “what’s open in the tracker since last run?” and pushes any new items into the channel. Crude but bullet-proof against missed events.

Footguns

A closed session is a black hole. Channels are research preview, but the at-most-once behavior is by design, not a bug. Anyone who treats them as “Claude will catch up on what I sent overnight” loses every event sent before they restarted Claude. The defense is one of: a persistent process supervisor, a sender with retry, or a periodic poll that re-sends unhandled work. Pick at least one before relying on channel-driven workflows.

Sender allowlist drops silently. A misconfigured sender (wrong Telegram bot ID, wrong Discord webhook URL, message from an off-allowlist account) is dropped with no log line in the Claude session. Diagnostic: check the channel plugin’s own logs (Bun process stderr) before assuming Claude is broken. The plugin sees the dropped message even when Claude does not.

Permission prompts are unattended-mode poison. A new tool the model has not been granted shows a prompt; the prompt blocks the session; nothing else processes until you respond. If you launched the unattended session with normal default mode and forgot to pre-approve common tools, the first MCP query call sits unanswered. Either pre-approve in .claude/settings.json (allowlist Bash/MCP tools you actually use), launch with permission relay, or accept the risk of --dangerously-skip-permissions in a sandboxed env.

Replies vanish from your terminal. Two-way channels send Claude’s reply to the chat or webhook target, not to the terminal where you are watching. A common debugging pattern fails: you send a test message, Claude replies “I did the thing”, and you see nothing locally. The reply went to the channel target. Check Telegram, Discord, or wherever the channel terminates, not stdout.

Channel plugins require Bun, even when your project does not use Bun. The supported channels (Telegram, Discord, iMessage) all ship as Bun-based plugins. A repo built around Node may suddenly need Bun installed alongside it just to run channels. Install Bun globally (curl -fsSL https://bun.sh/install | bash) and accept the second runtime, or build your own non-Bun channel using the channels reference and run it directly via .mcp.json instead of the plugin marketplace.

When NOT to use channels

  • You need exactly-once delivery. Channels do not provide it. If a payment notification absolutely must trigger exactly one action, put a real queue in front, deduplicate by event id, and call the channel only after the queue has marked the event handled.
  • Your event volume is high. Channels are designed for human-rate events (chat messages, alerts, occasional webhooks). Pumping a thousand events a minute through a channel into a single Claude session burns context and produces nonsense; that workload belongs in a real worker, with Claude only invoked on a summarized batch.
  • The work is fully unattended for hours. A long-running unattended Claude with --dangerously-skip-permissions is a process that may eventually do something destructive nobody approved. Run channels on a schedule or with a watcher that restarts and reviews what was done; do not leave them alone for a week.
  • You only need one-way alerts. A Slack webhook that just pings you when something happens does not need Claude in the middle. Channels earn their keep when Claude is supposed to act, not just when you want a notification.
  • You are running on Bedrock, Vertex, or Foundry without verifying support. Channel plugins talk to the Claude Code session, not to the model API directly, but the underlying session has to authenticate somewhere. Verify your provider supports the unattended workflow before building on it.

Sources

  • Push events into a running session with channels
    Authoritative: channels run as plugin-MCP servers; events arrive only while a session is open; sender allowlist silently drops unknown senders; two-way reply support; permission relay capability for unattended use.
  • Model Context Protocol
    How channels relate to other MCP servers: a channel is an MCP server with a `notify`-shaped tool that pushes into the live session, rather than a tool the model calls.
  • Permission modes
    Why an unattended channel session needs permission relay or `--dangerously-skip-permissions`: a normal permission prompt pauses the session waiting for human approval that no one is there to give.

Was this helpful?