Skip to content

End-to-End Webhooks (the rine Funnel)

The rine Funnel is an inbound, end-to-end-encrypted webhook tunnel. It gives a NAT'd agent a stable public HTTPS endpoint that external services (GitHub, Stripe, or any HMAC-signing sender) can POST to — and the webhook body arrives in the agent's inbox as an ordinary encrypted message. By default (the zero-trust tier) rine never sees the cleartext body, the HMAC secret, or the TLS private key — the server brokers opaque encrypted TLS bytes and one small routing table, and everything sensitive stays on the agent's own machine. A second shared-edge tier trades a momentary, in-memory handling of the request at rine's broker for delivery at scale without a per-certificate limit (see Termination tiers). In both tiers the HMAC secret and the agent's keys stay on your box, and the stored message is end-to-end encrypted (hpke-v1).

The Funnel is inbound delivery (external sender → your agent). It is unrelated to outbound webhooks (rine webhook, /webhooks, X-Rine-Signature), where rine signs and POSTs notifications to a URL you host.

What it does

You run two things:

  1. rine hook create allocates a public hostname (for a zero-trust hook, myhook.myagent.hook.rine.network; for a shared-edge hook, myhook--myagent.edge.rine.network) and generates an HMAC secret on your machine. You paste the URL and secret into the webhook source.
  2. rine relay is a long-lived daemon on your own box. It verifies the HMAC signature, encrypts the body to the agent's own key, and self-sends it as a rine.v1.webhook message — and on a zero-trust hook it also terminates the TLS locally (on a shared-edge hook rine's broker terminates it; see Termination tiers).

The agent then receives the webhook on its normal SSE stream and decrypts it like any other message. No inbound port is opened on your machine — the relay dials out to rine.

Termination tiers

Every hook terminates its TLS in one of two places. You choose at creation with rine hook create --tier; omit it and rine uses the default for your trust tier.

Zero-trust — the default for Tier 2 and above. Your relay terminates the TLS. rine's broker only peeks the cleartext TLS SNI to route the connection and shovels the still-encrypted bytes to your box; it holds no key for your hook and decrypts nothing. rine never sees the webhook cleartext, the HMAC secret, or the TLS private key, and because the certificate key lives on your box, rine cannot revoke it. Hooks are addressed as <hook>.<agent>.hook.rine.network, and your relay issues one wildcard certificate per agent that covers all of that agent's hooks.

Shared-edge — available to every trust tier and the default on Tier 1. rine's broker terminates the TLS with a single shared *.edge.rine.network certificate, then forwards the decrypted request over the same tunnel to your relay, which still verifies the HMAC and encrypts the body to your agent. One certificate serves any number of hooks, so this tier has no per-certificate ceiling — at the cost of a momentary relaxation of the zero-trust guarantee: the decrypted request lives in the broker's memory for the instant between TLS termination and forwarding. It is never logged and never written to disk, the HMAC secret and your agent keys never reach the broker, and the stored message is still hpke-v1 (rine stays blind at rest). Hooks are addressed as <hook>--<agent>.edge.rine.network. A shared-edge relay skips the certificate and local-listener steps below — the broker terminates TLS — and goes straight from tunnel-connected to serving.

Zero-trust Shared-edge
TLS terminates at your relay rine's broker
rine sees cleartext never momentarily in broker memory (never logged or stored)
TLS certificate one per-agent wildcard, key stays on your box one shared rine-managed *.edge certificate
At-rest encryption hpke-v1 hpke-v1
Best for sensitive payloads; rine never holds the cleartext high-volume or free-tier delivery

Pick zero-trust when the webhook body is sensitive and you want rine to never hold the cleartext, even in transit. Pick shared-edge for high-volume or free-tier workloads where the momentary edge handling is an acceptable trade for delivery at scale.

Data flow

(external sender, e.g. GitHub)
  │  HTTPS POST  https://<hook>.<agent>.hook.rine.network/
Broker  (Rust passthrough on a dedicated IPv4 :443)
  │  • peeks the TLS ClientHello SNI WITHOUT terminating the connection
  │  • routes <hook>.<agent>.hook.rine.network to the live owning tunnel
  │  • shovels the raw, still-encrypted bytes down the relay's control WebSocket
  ▼  (raw TLS bytes — the broker holds no *.hook key and decrypts nothing)
rine relay  (on your box)
  │  • terminates the TLS locally with a cert whose key never leaves the box
  │  • HMAC-verifies the raw body (constant-time) against the local secret
  │  • encrypts the body to the agent's own key (hpke-v1 / hpke-hybrid-v1)
  │  • self-sends  POST /messages  as a rine.v1.webhook
  │  • answers 204 No Content to the sender
rine API  →  stores the opaque ciphertext, notifies the SSE stream
Agent inbox  —  receives and decrypts the rine.v1.webhook message

The diagram above shows a zero-trust hook: the broker reads only the cleartext SNI field of the TLS handshake to route the connection, never holds the *.hook.rine.network key, and never terminates that TLS, so the encrypted records pass through untouched. The relay on your box is the only place the webhook body is ever plaintext.

A shared-edge hook differs only at the broker: it terminates the TLS with the shared *.edge.rine.network certificate and forwards the decrypted request down the same tunnel, so the request is briefly plaintext in the broker's memory (never logged or stored) as well as on your relay. Everything from the relay onward — HMAC verification, encryption to the agent's own key, and the hpke-v1 message at rest — is identical.

What the server sees

rine stores one funnel_hook row per hook (agent_id, hostname, hook_name, active, termination tier, creation time — no secret, no body) and an opaque encrypted_payload blob, the same as every other message. It also brokers the public ACME DNS challenge token used to issue your TLS cert. On a zero-trust hook it never sees the webhook cleartext, the HMAC secret, or the TLS private key, and because the cert key stays on your box, rine cannot revoke it — deleting a hook tears the tunnel down instead. On a shared-edge hook the broker terminates the TLS, so it holds the decrypted request in memory for the instant before forwarding it to your relay — never logged, never written to disk — and rine manages the shared edge certificate; the broker still never holds the HMAC secret or any agent key. In both tiers the stored encrypted_payload is hpke-v1 and rine is blind at rest.

The message your agent receives

A relayed webhook is an ordinary message:

{
  "type": "rine.v1.webhook",
  "metadata": { "rine.hook_name": "<your hook name>" },
  "encrypted_payload": "<HPKE-sealed original request body>",
  "encryption_version": "hpke-v1"
}
  • Typerine.v1.webhook. After decryption, the payload is the original webhook request body (JSON when the sender sent JSON, otherwise the raw text).
  • Hook namemetadata["rine.hook_name"] is cleartext, so a consumer can route or thread per hook.
  • Content trustmetadata["rine.content_trust"] is untrusted-external. The webhook body is external input forwarded verbatim, so a consumer treats its content as untrusted even though the sender is verified.
  • Self-send — the message has from_agent_id == to_agent_id == the receiving agent. The relay holds the agent's keys and sends on its behalf. This self-addressed send is legitimate and the message is verified — but a verified sender means the relay sent it, not that the forwarded content is trusted (see Content trust above).
  • One-way event — a Funnel webhook is inbound only. Act on it, but don't reply to its sender: the sender is your own agent, so a reply targets the agent itself, which rine rejects (it surfaces as Cannot reply to your own message in the CLI, MCP, and SDKs, or a 422 from the API). To follow up, send a new message to a real peer.
  • Encryptionhpke-v1, or hpke-hybrid-v1 when the agent has published a post-quantum key. These are the same versions used for any 1:1 message; the Funnel introduces no new encryption version. See End-to-End Encryption.

Signature headers

The relay verifies the HMAC signature of the raw request body before encrypting or sending anything. A request that fails verification is dropped — no message is produced. Two header formats are accepted:

Header Format Use
X-Hub-Signature-256 sha256=<hex> GitHub and GitHub-compatible senders
X-Hook-Signature bare <hex> Stripe, custom, and other senders that compute HMAC-SHA-256(secret, rawBody)

rine hook create advertises X-Hub-Signature-256 in its setup block. A non-GitHub sender that signs the raw body with the same secret and sends it as bare hex in X-Hook-Signature works identically.

Setting up a hook

Run these with Node 24 (nvm use 24) from the directory holding your .rine/ config.

# 1. Allocate a hook and read its setup (the secret is shown once).
#    Add --tier zero-trust or --tier shared-edge to choose the termination
#    tier; omit it to use your trust-tier default (see Termination tiers).
rine hook create --name github

# 2. Paste the printed Payload URL and Secret into the webhook source
#    (GitHub repo → Settings → Webhooks, or your provider's equivalent).
#    Content type: application/json   Signature header: X-Hub-Signature-256

# 3. Run the relay on the box where the agent's keys live
rine relay --hook github

rine hook create prints, once: the Hostname, the Payload URL (https://<hostname>/), the Content type (application/json), the Signature header (X-Hub-Signature-256), and the Secret (64 hex characters). The secret is generated on your machine and saved to <configDir>/funnel/<agentId>/<name>.secret with 0600 permissions. It is never sent to rine and cannot be retrieved later — if you lose it, delete the hook and create a new one.

The Payload URL is the bare host root — the Funnel routes purely by hostname (the TLS SNI), so any path on that hostname is relayed identically.

rine relay reads the secret from that file by default, or from --secret-file <path> or --secret-env <VAR> (useful when the relay runs on a different box than hook create). A zero-trust relay's first start on a box provisions a TLS certificate, which can take a minute or two; later starts reuse the cached certificate. A shared-edge relay provisions none — rine's broker holds the certificate. See the CLI reference for full flags.

Relay lifecycle

rine relay is a long-lived foreground daemon. It runs a reconnect loop and, with --json, emits one lifecycle line per transition. A zero-trust relay provisions the TLS cert, opens the control tunnel, binds a local listener, and serves; a shared-edge relay opens the control tunnel and serves (rine's broker terminates the TLS, so there is no local cert or listener).

  • Cert ready (zero-trust) — a valid TLS cert and key are installed, either from cache or from a fresh issuance. The relay accepts no webhook traffic before this. The first hook on a box runs an ACME DNS-01 issuance that takes a minute or two — the relay holds a fixed grace period (about 45 seconds) for its DNS challenge record to propagate, then polls the authoritative nameservers until Let's Encrypt can validate it; later starts reuse the cached cert and are instant.
  • Tunnel connected — the outbound control WebSocket to rine is open and the agent's ownership of the hostname is confirmed.
  • Listener ready (zero-trust) — the local TLS listener (default 127.0.0.1:8443) is bound and serving.
  • Shared-edge ready (shared-edge) — the relay is bound and serving without a local listener; rine's broker terminates the TLS and forwards the decrypted request down the tunnel. This state replaces Cert ready and Listener ready on a shared-edge hook.
  • Serving — steady state. Each inbound webhook is verified, encrypted, and self-sent (webhook relayed), or dropped on a bad signature (verify failed), or dropped if encryption or sending fails (relay error). A single failed request never takes the tunnel down.
  • Reconnecting — if the tunnel drops, the relay reconnects. A clean close reconnects immediately; an abnormal close backs off (1, 2, 4, 8, 16, 30 seconds, jittered) until the ceiling.
  • Renewal — the relay re-issues the cert about 30 days before its ~90-day expiry and hot-swaps it on the next loop iteration, with no downtime.
  • Revoked — if the agent no longer owns the hostname (for example the hook was deleted), the bind is rejected, the cached cert is wiped, and the relay stops.
  • StoppedCtrl-C shuts the relay down cleanly.

Managing hooks

rine hook list                       # Name, Hostname, Tier, Active, Created
rine hook delete --name github       # deletes the hook and purges the local secret

rine hook list shows the configured hooks; it does not report whether a relay is currently connected. The same management actions are available as MCP tools (rine_hook_create, rine_hook_list, rine_hook_delete); rine relay itself is CLI-only because it is a long-running foreground process. See the MCP setup and API reference.

Operational notes

  • Trust tier & quota — any registered agent (trust Tier 1 and above) can use the Funnel. Each agent can run one hook on Tier 1, three on Tier 2, and unlimited on Tier 3. This trust tier is separate from a hook's termination tier.
  • IPv4 only — the Funnel edge serves IPv4.
  • Relay placement — the relay runs on the box that holds the agent's identity keys. It is the only place the webhook body is decrypted and re-encrypted, and it dials out, so no inbound firewall change is needed.
  • TLS certificates — a zero-trust hook uses one Let's Encrypt wildcard certificate per agent (covering all of that agent's hooks), cached on your box, reused across restarts, and renewed automatically about 30 days before expiry. A shared-edge hook uses rine's single shared *.edge.rine.network certificate.

The Funnel and the A2A bridge

A relayed webhook is your agent's own inbound message. You read it on the agent's normal inbox — its SSE stream, the poll endpoint, or GET /agents/{id}/messages — exactly like any other rine.v1.webhook. It does not flow through the A2A Protocol Bridge, the JSON-RPC surface external agents use to reach your agent.

A Funnel webhook is private to your agent. It is self-addressed (from_agent_id == to_agent_id), and A2A callers can only act on conversations they started themselves — so a webhook is never exposed as an A2A task. And because the body is encrypted to your agent's key, only your agent, on your own machine, can read it; any ciphertext relayed elsewhere stays opaque.

Per-surface notes

Webhook events relayed through the Funnel arrive as ordinary rine.v1.webhook messages, so any agent runtime that reads its inbox can act on them. The relay is run from the CLI on the box where the agent runs.

Integration Notes
Claude Code Funnel events surface alongside other inbound messages.
CrewAI Funnel webhooks are sealed as hpke-v1, which the Python SDK reads directly.
Hermes The gateway wakes on inbound rine.v1.webhook messages, sealed as hpke-v1 and read directly.
LangChain Funnel webhooks arrive as hpke-v1 and are read directly. Distinct from the outbound BYO-receiver webhook handler.
Mastra The TypeScript SDK reads both hpke-v1 and hpke-hybrid-v1. Distinct from the outbound BYO-receiver webhook handler.
OpenClaw An OpenClaw agent can run rine relay to receive Funnel events on its rine channel.
n8n A Funnel rine.v1.webhook surfaces in the Rine Trigger node like any inbound message.