Log in
DocsTriggers and webhooks

Triggers and webhooks

Give an outside service an address to send events to, and 0bridge stores them and does one of two things with each: runs a command on one of your machines, or hands it to a session that is listening for it (Claude Code, Codex, any shell), which handles it and listens again. It replaces a timer that polls the service every few minutes.

Last updated

How it works#

Each webhook is a private address like https://0bridge.dev/hook/wh_…. The service posts to it, 0bridge checks the request, stores the event and answers with a success code within milliseconds. Then the webhook's route runs, a moment later, on our side. So a slow command or agent never makes the sender wait or retry.

0b webhook add support --preset channeltalk
0b webhook list
0b webhook test support
0b webhook listen support

add asks what each event should do (or takes --route), then prints the address and the secret once, and the setup steps for the preset. The dashboard's Triggers page does the same with a form. set changes what a webhook does, rotate makes a new secret, rm deletes the webhook, and 0b webhook events shows what arrived and what each route did.

Checking who sent it#

Every webhook has one of four checks. The preset picks it; for a custom sender, the verify field of POST /api/triggers sets header or query mode:

ModeHow the sender proves itselfUsed by
StandardThe Standard Webhooks signature headers, signed with a whsec_ secret, within 5 minutesThe generic preset
GitHubThe X-Hub-Signature-256 header, an HMAC of the bodyThe GitHub preset
HeaderA shared secret in a header you nameCustom senders
QueryA secret in the address, as ?token=: 0bridge's, or one the sender makes (0b webhook token)Channel Talk

A request that fails the check gets a 401 and is not stored; the webhook shows the last refusal (0b webhook list), so a sender with the wrong secret doesn't fail silently. Repeated deliveries of the same event are recognized and answered with a success, once.

Routes#

After an event is stored, one of two things happens. 0b webhook add asks which; agents and scripts pass --route.

Route--routeWhat happens
RunrunA command you set on one of your machines runs, with the event on its input.
Listenlisten (the default)Nothing runs. A session waiting with 0b webhook listen <name> gets the event and handles it itself.

Forwarding to your own server, starting an agent, calling a Claude Code Routine or sending yourself a message is what a command does now: a few lines of your own script, run for each event. (Webhooks used to have forward, agent, notify and Routine routes. They were removed on 2026-10-14; a webhook that had one keeps its address and events, now as listen, and says so in 0b webhook list.)

Either route can also notify you (--notify: one notification per event, at most one a minute, through your ntfy topic or email). A webhook can limit which event types run its route; every event is stored either way, so a session can listen to any webhook, whatever its route, and agents can read all of them with bridge__events_poll. (Listen was called "store", and before that "queue"; --route store, --route queue and "kind": "queue" in the API still work and mean the same.)

Listen: a session waits for the next event#

0b webhook listen support

It waits, then prints the webhook's next event as JSON ({"id", "eventId", "hook", "type", "receivedAt", "data"}, what a run command gets on its input) and ends. That is the point: run it in the background from a session, and its end is the session's signal. Claude Code tells the session when a background command finishes, with its output; the session handles the event, then runs 0b webhook listen support in the background again. In a shell it is a loop:

while ev=$(0b webhook listen support); do printf '%s\n' "$ev" | ./handle.sh; done

Nothing is missed between two listens. This machine remembers, per webhook, the last event it handed out (~/.0bridge/webhook-listen/<webhook id>.json): the first listen starts from now, and an event that arrived while the session was busy comes at once on the next listen, oldest first, one per listen, with a note saying how many more wait. The position moves only once the event is printed, so a listen that's stopped hands the same event out again. Listens on the same machine share the position: an event goes to every listen waiting when it arrives, but one that waited goes only to the first listen after it. If two sessions each need every event, give each its own webhook.

The event comes over a connection 0bridge holds open (the gateway pushes it; nothing polls), and the listen reconnects by itself if the network drops. If the connection can't be made at all, it says why and ends: the webhook was deleted, or the 0bridge server doesn't have listen yet. Any device signed in with 0b login can listen (scoped and project tokens can't); the data is outside input, as everywhere here (the note on its output says so). A webhook named on or off is listened to by its id (0b webhook listen wh_…).

0b webhook events support --follow is for a person watching: it prints every event as it comes, never ends, and remembers nothing. listen is for a session or a script that acts on each event.

0b webhook listen without a name (and listen on|off) is something else: the background service that runs commands for run routes, below.

Run: a command on your machine#

cd ~/work/support-sync
0b webhook run support -- python3 sync.py

Everything after -- is the command, exactly as you typed it, and it runs in the folder you ran this in. It stays on that machine, in ~/.0bridge/webhooks.json: 0bridge's servers never see the command, its folder or its output. 0b webhook run switches the webhook's route to run (it asks first if the webhook did something else; --yes doesn't ask), keeps this machine connected for it (the webhook background service, 0b webhook listen without a name), and offers to send a test event.

For each event, the command gets:

  • On its input: the event as JSON, {"id", "eventId", "hook", "type", "receivedAt", "data"}, where data is what the service sent.
  • In its environment: ZEROBRIDGE_EVENT_ID (0bridge's ev_… id), ZEROBRIDGE_EVENT_SENDER_ID (the service's id for it), ZEROBRIDGE_EVENT_TYPE, ZEROBRIDGE_HOOK, ZEROBRIDGE_EVENT_RECEIVED_AT (milliseconds) and ZEROBRIDGE_EVENT_COUNT. Only plain values (letters, digits and ._:/-) are set; anything else is left out.

The command runs without a shell, so nothing the sender wrote can become part of a command line. (It also means no |, && or ~: put those in a script.) It runs with the runner's environment, which for the background service can be narrower than your terminal's, so give a full path if a command isn't found. Only its exit status, how long it took and a short reason go back to 0bridge, and show on the event: exit 0 on mbp, 2.1 s. Its output goes to ~/.0bridge/webhook-runs/<name>.log on that machine.

Options, set on the machine (the timeout and debounce are reported to 0bridge, and --machine is saved on the webhook's route; the command itself never is):

  • --timeout 300: seconds a run may take (up to 3,600). Past it, the command is ended with everything it started.
  • --debounce 30: wait this many seconds after an event; everything that arrived meanwhile runs once, with the newest event on the input and ZEROBRIDGE_EVENT_COUNT saying how many. Good for a sync script that reads everything new anyway.
  • --machine <name>: only that machine runs it, when several have a command for the same webhook.
  • --off: forget the command on this machine. The webhook's route stays run, for other machines that have one.

One run at a time per webhook on a machine; the next waits its turn. When no machine with a command for it is online, events wait, up to 7 days, and run when one connects. A webhook starts at most 500 runs a day, and at most 100 wait for a machine; events past either are stored but not run, and say why. Delivery is at least once: an event is run again only if the machine didn't report back in time (it went offline mid-run and didn't come back within its timeout plus 2 minutes, or it ran past its timeout by more than 2 minutes), and the machine keeps a journal of the last 500 events it ran (~/.0bridge/webhook-runs.json), so the same event isn't run twice there. A command that has to be exactly once can check ZEROBRIDGE_EVENT_ID itself.

0b webhook list shows which webhooks run a command on this machine, and which other machines have one.

Treat the data as untrusted#

A webhook's body is written by whoever can reach that address, or by the service's other users. It can contain instructions meant for your agent, or text meant to reach a shell. So:

  • A run route's command gets the event only on its input and in plain environment values, never in its arguments, and there's no shell. What your script does with the data is up to it: treat it as input from strangers.
  • A listening session gets the event as JSON, with a note that its data is outside input. A coding agent handling it should treat it as data, not instructions; permission prompts still come to you, and the usual deny rules still apply: Your agents from your phone.

Limits and retention#

  • Events are kept 7 days by default, up to 30. At most 10,000 events or 100 MB are stored per account, and the oldest go first.
  • 20 webhooks per account, 256 KB per event, 5,000 events a day. A machine can have commands for up to 20 webhooks.
  • A webhook can be turned off. A disabled or unknown address answers 404, the same either way.
  • Events are readable by 0bridge so they can be routed. They are not sealed.
  • Team routines (shared webhook templates) are listen webhooks, optionally with "also notify"; a command is set on each person's own webhook.

Reading events from an AI#

Two tools, for coding agents, and for chat apps while Webhook events in chat apps is on under Settings → AI apps (on for new accounts; turning it on needs you to confirm it's you, turning it off works from any AI with bridge__settings_off or 0b settings off chat-events):

  • bridge__events_list: your webhooks, what types they have seen and when, and what each one does. No secrets, no commands.
  • bridge__events_poll: new events since a cursor, with endpoint, types, limit and an optional wait of up to 20 seconds. Each event's data is cut at 8 KB, and the tool says it is untrusted outside input.

MCP events#

To the same callers, 0bridge offers your webhooks' events as one MCP event, webhook.received, with two optional filters: webhook (its name) and types. A client that supports MCP events (protocol 2026-07-28, as ChatGPT does in Work chats and dots) subscribes with events/subscribe and gets each new event POSTed to its address, signed with the Standard Webhooks headers, once per event and retried with the same id if it fails. The address must be public https, and it has to answer a signed verification first. Each event carries webhook, type, id, senderEventId, receivedAt and data (cut at 64 KB). Repeated deliveries from the sender aren't passed on twice. Events missed while the client was unreachable for a long time aren't replayed. The same mechanism carries host tasks' questions and results: Dots and your dev host.

events/poll, from the MCP Triggers and Events working group's draft, still answers for clients that poll; it is experimental and the draft may change. events/stream isn't there. Claude has no MCP events, so for it bridge__events_poll is how it finds out; Claude Code and other coding agents can also listen (0b webhook listen <name> in the background), and for a script, use a run route or listen.

Recipe: Channel Talk without a polling timer#

A common setup: a script (sync.py here) that pulls new Channel Talk chats into your own system, run by a timer every 10 minutes. With a webhook it runs when a message actually arrives, within seconds, and not at all when nothing happens.

  1. On the machine that has the script, make the webhook. It runs a command here:

    0b webhook add support-inbox --preset channeltalk --route run
  2. In Channel Talk's Desk, go to Settings, then Webhook, paste the address without its ?token=… part (https://0bridge.dev/hook/wh_…), and choose the event message.created.userChat. Save.

    Channel Talk adds a token of its own to the address it calls (?token=…), so 0bridge has to check that one. Copy the token Desk shows for the webhook and give it to 0bridge (typed hidden):

    0b webhook token support-inbox
  3. In the folder with the script, set the command. With --debounce 30, a burst of messages runs the script once:

    cd ~/work/support-sync
    0b webhook run support-inbox --debounce 30 -- python3 sync.py

    If the script needs API keys from your 0bridge vault, run it through 0b exec, which puts them in its environment without writing them anywhere:

    0b webhook run support-inbox --debounce 30 -- 0b exec -- python3 sync.py
  4. Send a test (0b webhook test support-inbox) or a real message, then check 0b webhook events support-inbox: the route says exit 0 on <your machine>. The script's output is in ~/.0bridge/webhook-runs/support-inbox.log.
  5. Turn the timer off. Or keep a slow one, every hour or so, as a safety net for the time the machine was off for more than 7 days.

What the script gets: the newest event's JSON on its input (Channel Talk's own payload is under data, with the message in data.entity), and ZEROBRIDGE_EVENT_COUNT saying how many events this run covers. A script that fetches everything new since its last run can ignore the input altogether; one that handles a single message can read it:

import json, sys
event = json.load(sys.stdin)
message = event["data"].get("entity", {})
print(event["type"], message.get("plainText"))

Three things to know:

  • Channel Talk turns a webhook off after 100 failures in a row. 0bridge answers with a success for every event it stored, including repeats and ones whose route failed, so a failing script doesn't switch it off. Two things do get errors: a wrong token (401: run 0b webhook token again with the token Desk shows; 0b webhook list shows when the last delivery was refused), and going over the daily limit of 5,000 events or 60 a minute from one address (429).
  • Channel Talk's event field names are the one part we haven't confirmed against a live delivery as of 2026-10-05 (its deliveries did arrive, with its own ?token=). The event type is read from the body's type and event fields when both are there, and is plain event otherwise. 0b webhook events shows exactly what arrived, and that is the place to check before you filter on types.
  • Channel Talk has no signature header we know of, so its token in the address is the only check. If the address leaks, make a new token in Desk and run 0b webhook token support-inbox again.

The same works for a session instead of a script: a coding agent runs 0b webhook listen support-inbox in the background, reads each chat as it comes and drafts a reply for you, then listens again.