Skip to content
Beam Docs

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 canA 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 manage on 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.secret

The 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:

OptionEffect
--payload-mode rawPOST 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.
--quietPrint 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.secret

accept 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 https URL, without user information or a fragment.
  • --allow-private lets it reach a private address, such as a service on the host's own network. See Network rules.
  • accept also takes --payload-mode and --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:

EventSent when
messageA message is published on a message channel the webhook subscribes to.
fileA file on an object channel the webhook subscribes to is within the size cap; the request carries its bytes.
file.noticeThe file is larger than the size cap; the request carries its metadata only.
testThe 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

ModeMessageFile within the capLarger fileTest
envelope (default)JSON envelopemultipart/form-data: an envelope part (JSON) and a file partJSON envelope of type file.noticeJSON envelope of type test
rawThe published body unchanged, with its content typeThe file's bytes, application/octet-streamEmpty bodyEmpty 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 text when it is valid UTF-8, and in payload_base64 otherwise.
  • reply_to_publication_id is present when the message is itself a reply.
  • A file or file.notice envelope carries file: {name, size_bytes, attached, max_file_bytes}, where attached says whether the request carries the bytes. publisher_member_id is the member that published the file.
  • A test envelope has a random id and no channel or publication.

Headers

HeaderValue
X-Beam-Eventmessage, file, file.notice, or test.
X-Beam-RoomThe Room ID.
X-Beam-Webhook-MemberThe webhook's member ID.
Idempotency-KeyThe publication ID, or the test ID. It is the same on every attempt of one delivery.
X-Beam-ChannelThe channel ID, when there is one.
X-Beam-PublicationThe publication ID, when there is one.
X-Beam-PublisherThe member that published it, when known.
X-Beam-HopThe message's hop count. Messages only.
X-Beam-Reply-ToThe publication this message replies to, when it is a reply.
X-Beam-File-NameThe file name, percent-encoded. Files only.
X-Beam-File-SizeThe file size in bytes. Files only.
X-Beam-TimestampThe Unix time, in seconds, that the signature covers.
X-Beam-SignatureOne 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:

  1. The timestamp is within 300 seconds of your clock.
  2. At least one v1 signature matches one of your secrets. Compare in constant time and ignore any scheme other than v1.

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 False

Pass 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.

ResponseResult
2xxDelivered.
5xx, 408, 429, a network error, or a timeoutRetried: up to 3 attempts in all, 1 second and then 2 seconds apart.
401 or 403Failed, not retried. The webhook becomes degraded with reason auth.
410Failed, not retried. The webhook is paused at once with reason gone. Use it to stop deliveries.
Any other status, including a redirectFailed, 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 2xx status.
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 3xx response 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.

StateMeaningWhat to do
pendingWaiting for its host to accept it with the URL and secret.Accept it on the host's machine.
activeDelivering.
degradedStill 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.
pausedNot 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 showsMeaning
host offline: not deliveringThe host's agent is offline. Delivery resumes when it is back online.
webhook offline: not deliveringThe 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 channelsThe 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-Publisher is 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; 0 ends 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:

KindCauseWhat to do
webhook_manage_unavailableThis machine's agent, or Beam's coordinator, does not support webhook members.beam update, then beam agent restart.
webhook_host_unavailableThis 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_agentThis 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_ineligibleThe named host is not an active agent member connected to a Beam account, or its agent lacks hosting support.Name another member.
webhook_permission_deniedAdding, moving, removing, or resuming the webhook needs the Room owner or admin role.Ask a Room owner.
webhook_host_permission_deniedThis machine's agent is not the webhook's host.Run host commands on the host's machine.
webhook_grant_not_allowedA --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_retentionA granted message channel does not allow retention none.Allow none on the channel, or leave it out.
webhook_controller_upgrade_requiredA 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_reachedThe Room already holds 10 webhooks, or the host agent 20.Remove one, or name another host.
webhook_state_conflictThe 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_foundThe 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_acceptedA 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_requestThe 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_invalidThe 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_failedThe test event did not reach the receiver successfully.Follow the hint for the response class.

Next steps

Decentralized distributed bandwidth infrastructure.

On this page