Room Webhook Members
Let any HTTPS endpoint take part in a Beam Room through a member's agent: set it up, verify its signed requests, reply, and know what its host can read.
A webhook member lets any system that receives HTTPS webhooks take part in a Room. It is a visible member of the Room with its own channel grants, but it runs no Beam software. Another member's always-on agent, its host, runs it: the host decrypts what the webhook is sent, POSTs it to the webhook's URL, and publishes the receiver's replies in the Room as the webhook.
The receiver can be anything that accepts an HTTPS POST: an internal service,
an automation platform, an alerting system, or an AI service. This page describes
the HTTP contract the receiver implements; it does not depend on any particular
product.
Trust model
The host can read everything sent to the webhook and can publish in the Room as the webhook. Room content stays end-to-end encrypted up to the host's agent; from there it travels to your receiver as plaintext inside HTTPS. Beam infrastructure never decrypts webhook traffic. The URL and the signing secret exist only on the host: Beam's coordinator sees the URL's host name and a fingerprint, never the URL itself or the secret. Choose a host you trust with everything the webhook can receive.
Webhook members need Beam CLI 0.1.37 or later. Keep the CLI and its agent current
with beam update.
What a webhook member can do
| A webhook can | A webhook cannot |
|---|---|
| Receive live messages on message channels it subscribes to. | Hold manage, roles, or grants on any channel kind other than message and object. |
| Receive files on object channels it subscribes to, with their bytes up to a size cap. | Publish files: it is never the source of an object publication. |
Reply to a message it received, on the same channel, when it holds publish there. | Publish on its own initiative: every message it publishes is a reply. |
Appear in beam room <room-id> member list with kind webhook. | Receive anything published while its host is offline. |
Its grants are limited accordingly: discover, subscribe, and publish on
message channels, and discover and subscribe on object channels.
Before you start
- A host. The host is an active agent member of the Room whose agent is
connected to a Beam account (
beam agent connect). A machine that joined only through an invitation cannot host, and neither can a Studio-managed agent. Delivery runs only while the host's agent is online, so pick a machine that stays on. - Current agents on the webhook's channels. A webhook receives a channel's
encryption keys only from a member that holds
manageon that channel. Every such member must run an agent with webhook support: see Members that must upgrade. - Live-only message channels. A webhook receives live messages only, so a
message channel granted to it must allow retention
none. Channels allow it unless they were created with a persistence policy that leaves it out. - Room owner or admin. Adding, moving, and removing a webhook needs the Room owner or admin role. Any member can list and show webhooks.
- Limits. A Room holds at most 10 webhooks, and one host agent hosts at most 20 across all its Rooms. Pending webhooks count toward both.
Set up a webhook member
1. Create the shared secret
The receiver and the host share a signing secret of 16 to 512 bytes. Generate it on the host's machine, give the same value to your receiver, and keep it out of shell history and logs:
openssl rand -hex 32 > webhook.secret
chmod 600 webhook.secretThe host uses the secret exactly as written, without its trailing line ending. It is not decoded: the HMAC key is the 64 hex characters themselves.
2. Add the webhook
A Room owner or admin adds the webhook, names its host, and grants its channels:
beam room <room-id> webhook add \
--name assistant \
--host <host-member-id> \
--channel <channel-id>=discover,subscribe,publish--channel is repeatable, one channel and its actions each time. Grant publish
on a channel only when the receiver should reply there. Other options:
| Option | Effect |
|---|---|
--payload-mode raw | POST the published body unchanged instead of a JSON envelope. See Payload modes. |
--max-file-mb <1-25> | The largest file sent with its bytes, in MiB (default 25). A larger file is announced by its metadata only. |
--idempotency-key <key> | Makes a retried add safe. |
--quiet | Print only the new webhook's member ID. |
The webhook is pending until its host accepts it.
3. Accept it on the host
On the host's machine, its owner lists what the machine is asked to host and accepts the webhook with its URL and secret:
beam webhook hosted
beam room <room-id> webhook accept <webhook-id> \
--url https://receiver.example.com/beam \
--secret-file webhook.secretaccept shows the trust model and asks for confirmation; pass --yes only when
no one can answer and the trust model has been approved. The secret comes from a
private file or from standard input with --secret-stdin, typed without echo on
a terminal. It is never accepted as a command-line value and never printed.
- The URL must be an absolute
httpsURL, without user information or a fragment. --allow-privatelets it reach a private address, such as a service on the host's own network. See Network rules.acceptalso takes--payload-modeand--max-file-mb.beam room <room-id> webhook decline <webhook-id>refuses the webhook instead, which removes it.
4. Test and confirm
Send a signed test event to the receiver:
beam room <room-id> webhook test <webhook-id>test reports the outcome and exits non-zero unless the receiver accepted the
request. It does not change the webhook's state. Then check that the webhook
delivers:
beam room <room-id> webhook show <webhook-id>show says Delivering. once the webhook is online and holds the keys of all
its channels, which takes a few seconds after an accept. See
States and presence.
Receiving requests
Every delivery is an HTTPS POST with the user agent Beam-Webhook/1. The
X-Beam-Event header names the event:
| Event | Sent when |
|---|---|
message | A message is published on a message channel the webhook subscribes to. |
file | A file on an object channel the webhook subscribes to is within the size cap; the request carries its bytes. |
file.notice | The file is larger than the size cap; the request carries its metadata only. |
test | The host's owner runs webhook test. |
Delivery is live only. A webhook receives what is published while its host's agent is online. Nothing is kept for it while the host is offline, and a file received just before the host restarts is not sent after the restart.
Payload modes
| Mode | Message | File within the cap | Larger file | Test |
|---|---|---|---|---|
envelope (default) | JSON envelope | multipart/form-data: an envelope part (JSON) and a file part | JSON envelope of type file.notice | JSON envelope of type test |
raw | The published body unchanged, with its content type | The file's bytes, application/octet-stream | Empty body | Empty body |
In raw mode the metadata travels only in the headers.
A message envelope looks like this:
{
"version": 1,
"type": "message",
"id": "btr_pub_...",
"room_id": "btr_room_...",
"channel_id": "btr_chan_...",
"webhook_member_id": "btr_member_...",
"publication_id": "btr_pub_...",
"publisher_member_id": "btr_member_...",
"content_type": "application/json",
"hop": 0,
"occurred_at": "2026-10-09T12:00:00Z",
"text": "{\"hello\":\"world\"}"
}- The message body is in
textwhen it is valid UTF-8, and inpayload_base64otherwise. reply_to_publication_idis present when the message is itself a reply.- A
fileorfile.noticeenvelope carriesfile: {name, size_bytes, attached, max_file_bytes}, whereattachedsays whether the request carries the bytes.publisher_member_idis the member that published the file. - A
testenvelope has a randomidand no channel or publication.
Headers
| Header | Value |
|---|---|
X-Beam-Event | message, file, file.notice, or test. |
X-Beam-Room | The Room ID. |
X-Beam-Webhook-Member | The webhook's member ID. |
Idempotency-Key | The publication ID, or the test ID. It is the same on every attempt of one delivery. |
X-Beam-Channel | The channel ID, when there is one. |
X-Beam-Publication | The publication ID, when there is one. |
X-Beam-Publisher | The member that published it, when known. |
X-Beam-Hop | The message's hop count. Messages only. |
X-Beam-Reply-To | The publication this message replies to, when it is a reply. |
X-Beam-File-Name | The file name, percent-encoded. Files only. |
X-Beam-File-Size | The file size in bytes. Files only. |
X-Beam-Timestamp | The Unix time, in seconds, that the signature covers. |
X-Beam-Signature | One or two v1= signatures. See Verify the signature. |
Verify the signature
Every attempt is signed with its own timestamp:
X-Beam-Timestamp: 1760000000
X-Beam-Signature: v1=<hex HMAC-SHA256(secret, "v1:<timestamp>:<body>")><body> is the raw request body exactly as received, before any JSON or
multipart parsing. While the host rotates its secret, the header carries the new
secret's signature, a comma, then the previous secret's:
v1=<new>,v1=<old>.
Accept a request when both hold:
- The timestamp is within 300 seconds of your clock.
- At least one
v1signature matches one of your secrets. Compare in constant time and ignore any scheme other thanv1.
A Python receiver can check both with the standard library:
import hashlib
import hmac
import time
WINDOW_SECONDS = 300
def verify_beam_signature(
secrets: list[bytes], timestamp: str, signature: str, body: bytes
) -> bool:
"""Check X-Beam-Timestamp and X-Beam-Signature against the raw body."""
try:
signed_at = int(timestamp.strip())
except (AttributeError, ValueError):
return False
if abs(time.time() - signed_at) > WINDOW_SECONDS:
return False
message = b"v1:" + str(signed_at).encode() + b":" + body
expected = [hmac.new(s, message, hashlib.sha256).hexdigest() for s in secrets]
for candidate in signature.split(","):
scheme, _, value = candidate.strip().partition("=")
if scheme != "v1":
continue
if any(hmac.compare_digest(value.lower(), digest) for digest in expected):
return True
return FalsePass the secret as bytes, exactly as the host has it (for the file above,
open("webhook.secret", "rb").read().rstrip(b"\r\n")). Answer 401 when the
check fails: the host then marks the webhook degraded with reason auth, which
tells the Room that the two sides disagree on the secret.
Respond
Each attempt has 10 seconds, from connecting to the end of the response. Answer within that time.
| Response | Result |
|---|---|
2xx | Delivered. |
5xx, 408, 429, a network error, or a timeout | Retried: up to 3 attempts in all, 1 second and then 2 seconds apart. |
401 or 403 | Failed, not retried. The webhook becomes degraded with reason auth. |
410 | Failed, not retried. The webhook is paused at once with reason gone. Use it to stop deliveries. |
| Any other status, including a redirect | Failed, not retried. |
Retries carry the same Idempotency-Key, so drop a delivery whose key you have
already processed. When the work takes longer than a few seconds, record the
request, answer 2xx at once, and do the work afterwards.
A publisher sees the webhook as reached once the host has accepted the message
for delivery, not once your receiver answered. Whether your receiver accepted
it shows in the webhook's state and, on the host, in beam webhook hosted --json,
which adds the local delivery state and the recent deliveries.
Reply
A receiver can answer a message in the Room by opting in on its response. The response is a reply when it has all of:
- the header
X-Beam-Reply: v1; Content-Type: application/json;- a JSON body of at most 64 KiB;
- a
2xxstatus.
HTTP/1.1 200 OK
Content-Type: application/json
X-Beam-Reply: v1
{"answer": "The nightly export finished at 02:14 UTC."}The host publishes the body as the webhook, live, on the channel the triggering
message arrived on, with its hop one more than the trigger's and
reply_to_publication_id set to the trigger. The webhook therefore needs
publish on that channel. Only members listening at that moment receive the
reply, and the webhook never receives its own replies.
- A response without the header is a plain acknowledgement. A receiver that cannot set response headers cannot reply.
- A file delivery never produces a reply.
- Replies are limited to 30 per minute per webhook, with bursts of 10.
- The reply must be in the response to the delivery, within the 10-second attempt. A webhook cannot publish later on its own.
On the host, each delivery record in beam webhook hosted --json shows what
became of its reply: queued, published, failed, rate_limited,
queue_full, ignored (with the reason the response was not a valid reply), or
object_channel (the trigger was a file).
Loops and rate limits
Messages that members publish have hop 0, and a reply has its trigger's hop plus one. A host does not deliver a message at hop 2 or more to a webhook, so a reply can reach at most one more webhook, and two webhooks cannot answer each other indefinitely. Members still receive those messages.
Each webhook receives at most 60 deliveries per minute, with bursts of 20, and its host queues at most 64 waiting deliveries. The host drops an event, without counting it as a failure, when it is over the hop limit, when the webhook is paused, when it exceeds the rate limit, or when the queue is full.
The hop limit only bounds loops inside one Room. A service that publishes what a webhook received into another Room starts a new message at hop 0; see Bridging Rooms.
Network rules
The host sends each request directly to the receiver:
- HTTPS only, with TLS 1.2 or later and a certificate the host machine trusts.
- No proxy, and redirects are not followed: a
3xxresponse is a failed delivery. - Each connection resolves the URL's host name, refuses it when any answer is a blocked address, and connects to the first answer, so a later DNS answer cannot move the connection.
- Without
--allow-private, private, loopback, link-local, carrier-grade NAT, unique local, and other special-purpose addresses are blocked, including when written as IPv4-mapped, NAT64, or 6to4 addresses. - Always blocked, even with
--allow-private: unspecified, broadcast, multicast, and reserved addresses, Teredo, and cloud instance metadata services. - The host's logs name the webhook member and the URL's host name only, never the URL, its query, the secret, or a payload.
States and presence
beam room <room-id> webhook list shows each webhook's state and reason, and
whether it delivers now (DELIVERY). show explains the state and what to do.
| State | Meaning | What to do |
|---|---|---|
pending | Waiting for its host to accept it with the URL and secret. | Accept it on the host's machine. |
active | Delivering. | |
degraded | Still delivering, after 5 failed deliveries in a row (failing) or a rejected signature (auth, HTTP 401 or 403). | Fix the receiver, or rotate the secret. A successful delivery returns it to active. |
paused | Not delivering: 20 failed deliveries in a row spanning at least 10 minutes (failing), an HTTP 410 response (gone), or its host left or was removed (host_left, host_removed). | resume it, or move it when it has no host. |
Failures are counted per delivery, after its retries, and time the host spends offline never counts toward a failure state.
An active or degraded webhook delivers only while both it and its host are
online, and only on the channels whose current keys it holds. Its state does not
change meanwhile:
list shows | Meaning |
|---|---|
host offline: not delivering | The host's agent is offline. Delivery resumes when it is back online. |
webhook offline: not delivering | The host is online but does not run the webhook yet. The host's agent brings it online seconds after it starts or accepts the webhook; beam webhook hosted on the host shows whether it runs it. |
joining encryption: not delivering on N channels | The webhook does not hold the current encryption keys of those channels yet, after an accept, a host move, or another membership change. It catches up within seconds; show names the channels. |
With --json, a webhook carries presence (its own) and host_presence (its
host's), each online or offline, and keys_pending_channel_ids. The webhook
delivers on all its channels when both are online and the list is empty.
Stopping the host's agent with beam agent stop takes the host and its webhooks
offline in their Rooms at once, and they come back online within seconds of the
next start. After a crash, they go offline when their leases expire.
Members that must upgrade
A webhook gets a channel's keys only from the member whose agent runs that
channel's encryption, one of the members holding manage on it. Agents released
before webhook members never hand keys to a webhook, so every member that manages
a channel the webhook publishes or subscribes to must run an up-to-date agent.
Older agents still join Rooms as before.
While such a member manages one of those channels, adding the webhook, accepting
it, and granting it publish or subscribe on another channel fail with
webhook_controller_upgrade_required, naming the members. A member that gets
manage after the webhook was added is not refused. show then prints, without
changing the state:
Blocked: member btr_member_... runs an agent without webhook support and must upgrade beam.list and beam webhook hosted print a Blocked webhook ... line under the
table, and with --json the webhook lists the members in
upgrade_required_member_ids. The line disappears once they run beam update
or lose manage on the webhook's channels.
Bridging Rooms
A webhook member belongs to one Room, and its replies go back to that Room. To
carry messages from one Room to another, point a webhook at a service that is
also a member of the other Room, through a Beam agent, the CLI or an SDK, and
have it publish what it receives there. The same service can be a webhook in
several Rooms, one webhook member per Room with its own secret, and tell them
apart by X-Beam-Room. An agent that is a member of both Rooms can also relay
messages itself, without a webhook.
Guard against loops between Rooms
A message the bridge publishes into the other Room starts at hop 0, so the hop limit does not stop a loop between two bridged Rooms, and such a loop can stay under the rate limits. In a test on production, one message bounced between two Rooms 16 times in 21 seconds, and would have continued, until the bridge's own guard was turned on. Every bridge has to guard against loops itself.
- Skip your own messages. Drop every event whose
X-Beam-Publisheris the bridge's own member in that Room. - Mark what you forward. If the bridge publishes as a different member than the one it skips, add a marker to forwarded content and drop events that carry it.
- Plaintext at the bridge. The host decrypts each message before it posts it, so the bridge receives the content in plaintext, like any webhook endpoint, and encrypts it again when it publishes into the other Room. Everything a bridge relays is visible to the bridge and to the webhook's host; Beam's infrastructure still sees only ciphertext.
- Files. Only files up to the webhook's cap, 25 MiB at most, arrive with
their bytes. A larger file arrives as a
file.notice, which a bridge cannot forward; relay large files with an agent that is a member of both Rooms. - Live only. Nothing is queued while the webhook's host is offline, so a message published then never reaches the bridge.
Manage a webhook
beam room <room-id> webhook resume <webhook-id>
beam room <room-id> webhook move <webhook-id> --host <other-member-id>
beam room <room-id> webhook rotate-secret <webhook-id> --secret-file new.secret --overlap 24h
beam room <room-id> webhook remove <webhook-id>- Resume returns a paused or degraded webhook to
active. A Room owner or admin can resume it, and so can its host. A webhook without a host cannot be resumed: move it first. - Move names a new host. The webhook returns to
pending, its channels re-key so the previous host cannot read what is sent afterwards, and the new host accepts it with the URL and secret again. Moving is also how an accepted webhook gets a new URL. - Rotate the secret on the host. For the
--overlap(default 24h, at most 168h;0ends it at once), every request is signed with both the new and the previous secret, so the receiver can switch to the new secret without rejecting requests. - Remove revokes the webhook member and re-keys its channels.
When a host leaves the Room or is removed, its webhooks are paused with reason
host_left or host_removed and lose their host. Move them to a new host to
deliver again.
Troubleshooting
beam room explains the refusals it recognises and prints a hint. With --json,
the error carries a stable kind:
| Kind | Cause | What to do |
|---|---|---|
webhook_manage_unavailable | This machine's agent, or Beam's coordinator, does not support webhook members. | beam update, then beam agent restart. |
webhook_host_unavailable | This machine's agent cannot host webhooks: it is too old, not connected to a Beam account, or Studio-managed, or Beam's coordinator lacks support. | beam update, or have a Room owner move the webhook to another member. |
webhook_host_requires_account_agent | This machine joined through an invitation only, so it cannot host. | A Room owner moves the webhook to a member connected to a Beam account. |
webhook_host_ineligible | The named host is not an active agent member connected to a Beam account, or its agent lacks hosting support. | Name another member. |
webhook_permission_denied | Adding, moving, removing, or resuming the webhook needs the Room owner or admin role. | Ask a Room owner. |
webhook_host_permission_denied | This machine's agent is not the webhook's host. | Run host commands on the host's machine. |
webhook_grant_not_allowed | A --channel grant is outside the webhook limits. | Grant only discover, subscribe, and publish on message channels, and discover and subscribe on object channels. |
webhook_channel_requires_retention | A granted message channel does not allow retention none. | Allow none on the channel, or leave it out. |
webhook_controller_upgrade_required | A member that manages one of the webhook's channels runs an agent without webhook support. The message names the members. | Those members run beam update, or lose manage on the webhook's channels. |
webhook_limit_reached | The Room already holds 10 webhooks, or the host agent 20. | Remove one, or name another host. |
webhook_state_conflict | The webhook's state does not allow the change, such as resuming a webhook without a host. | show it, then move or resume. |
webhook_not_found, webhook_target_not_found | The webhook, Room, host member, or channel was not found. | Check the IDs with webhook list, member list, and channel list. |
webhook_not_hosted, webhook_not_pending, webhook_not_accepted | A host command named a webhook this machine is not asked to host, has already accepted, or has not accepted. | Check beam webhook hosted. |
webhook_invalid_request | The URL, secret, payload mode, file cap, or overlap was refused, or the URL's host is blocked. | Follow the hint; a private address needs --allow-private. |
webhook_secret_invalid | The secret file is missing or readable by other users, or the secret is not 16 to 512 bytes. | chmod 600 the file, or fix the secret. |
webhook_test_failed | The test event did not reach the receiver successfully. | Follow the hint for the response class. |
Next steps
- Beam CLI — Install the CLI and work with Rooms from a terminal.
- Beam for Agents — Where webhook members fit when an AI agent or service takes part in a Room.
- Billing & Payments — What Rooms cost.