Vesicle: Webhook Inbox/Overview

Vesicle: Webhook Inbox

A vesicle endpoint is a URL that accepts HTTP deliveries and stores them under your account. It exists independently of your Cells: you can create one, give it to a vendor, and read what arrives before writing any code. Binding it to a Cell and a path makes it deliver; until then it captures.

Endpoints are account-scoped. The URL is a write capability — holding it lets a sender deliver, and nothing else. Reading captures requires your session or an API token carrying vesicle:read.

The name fits the tissue metaphor: a vesicle is the membrane-bound package a cell uses to hold ingested material until it is processed.


Creating an endpoint

$ ribo vesicle endpoint create soro
  https://api.tissue.systems/hook/hk_9f3c8a1b...   (capture only — no route bound)

An endpoint id is hk_ followed by 32 lowercase hex characters, and the ingest URL is that id under https://api.tissue.systems/hook/. The endpoint answers from the moment it is created. No Cell has to exist, and nothing has to be deployed.

Ingest accepts any HTTP method and records it. There is no authentication on the URL itself — the id is the credential, so treat it the way you treat a password and rotate it if it turns up somewhere you did not put it.

A path suffix is kept: a delivery to /hook/hk_9f3c…/orders/created records the path /orders/created, and once the endpoint is bound that suffix is appended to the bound route.

Create it with a binding already in place if the Cell exists:

ribo vesicle endpoint create soro --cell santixs-new --route /api/posts

From the API, POST /v1/vesicle/endpoints (scope vesicle:write) takes the same fields:

curl -X POST https://api.tissue.systems/v1/vesicle/endpoints \
  -H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "soro"}'
{
  "id": "hk_9f3c8a1b4e7d2c6058a1b3d5f7092e4c",
  "name": "soro",
  "url": "https://api.tissue.systems/hook/hk_9f3c8a1b4e7d2c6058a1b3d5f7092e4c",
  "cell": null,
  "cell_address": null,
  "route": null,
  "verify": "none",
  "ack": "immediate",
  "retries": 8,
  "created_at": "2026-08-11T17:04:02Z",
  "rotated_from": null,
  "grace_until": null,
  "revoked_at": null,
  "captures": { "captured": 0, "delivered": 0, "retrying": 0, "rejected": 0, "dead": 0 }
}

In the dashboard, the Inbox section lists every endpoint with its ingest URL and a copy button, which is the practical way to get 32 hex characters into a vendor's settings screen. An AI assistant connected over MCP uses vesicle_endpoint_create.

What the sender sees

Case Response
Captured, endpoint unbound or ack = "immediate" 202 {"ok":true,"id":"cap_…","state":"captured"}
Bound and ack = "on-success" your Cell's response verbatim — status, content-type, body
Unknown, revoked, or past its rotation grace window 404 {"error":{"code":"not_found","message":"No such endpoint."}}
Body over 64 KiB 413 {"error":{"code":"payload_too_large",…}}
Over the per-endpoint rate limit 429
verify is configured and the signature fails 401, and nothing is stored

Reading what arrived

ribo vesicle tail follows an endpoint and prints deliveries as they land:

$ ribo vesicle tail soro
  POST · 412 bytes · content-type: application/json
  { "title": "...", "content_html": "...", "meta_description": "...", "tags": [...] }

That is the request as the vendor sent it, which is frequently not the shape their documentation describes. List and inspect stored captures afterwards:

ribo vesicle list soro                          # newest first
ribo vesicle list soro --state dead --since 2026-08-10
ribo vesicle show cap_7b21                      # headers and body, redacted by default
ribo vesicle show cap_7b21 > test/fixtures/publish.json

ribo vesicle show writes JSON to stdout, so a shell redirect turns a real delivery into a test fixture, in place of one written by hand from a doc.

The same two reads over the API are GET /v1/vesicle/endpoints/{id}/captures (a list, never carrying bodies) and GET /v1/vesicle/captures/{cid} (one capture verbatim). A list row:

{
  "id": "cap_7b21f4a90c3e5d81b6a2c4e0",
  "endpoint_id": "hk_9f3c8a1b4e7d2c6058a1b3d5f7092e4c",
  "received_at": "2026-08-11T17:05:41Z",
  "method": "POST", "path": "/", "size": 412,
  "content_type": "application/json",
  "source_ip": "203.0.113.9",
  "verify": "none",
  "state": "captured",
  "attempts": 0, "last_status": 0, "next_attempt_at": null
}

The detail form adds the request itself:

{
  "headers": { "content-type": "application/json", "authorization": "«redacted»" },
  "body": "{\"title\":\"…\"}",
  "body_encoding": "utf8",
  "redacted": true,
  "attempts_log": [{ "at": "2026-08-11T17:05:41Z", "status": 502, "error": null }]
}

body_encoding is utf8 for text and base64 for anything that is not valid UTF-8, so a binary payload survives the round trip.

In the dashboard the endpoint detail page is a capture timeline: each row expands to the verbatim request, with per-capture actions for replay, drop, and copy as curl.


Binding to a Cell

Binding sets the Cell and the path a capture is delivered to:

ribo vesicle endpoint bind soro --cell santixs-new --route /api/posts
ribo vesicle endpoint unbind soro          # back to capture-only

The Cell has to exist in your account before you can bind to it: a name that does not resolve is answered 404 and the bind is refused. A mistyped name would otherwise look healthy and dead-letter every delivery, which is the failure this feature exists to prevent. So the order is create the endpoint, hand out the URL, write and deploy the Cell, then bind — and the endpoint captures throughout.

Binding does not backfill. Deliveries captured while the endpoint was unbound stay in the captured state and are never dispatched on their own. Writing a handler is a code event, not a business event, and three weeks of a vendor's stored deliveries firing the instant a route appears is rarely what anyone wants. Those captures reach the Cell only through an explicit replay, one at a time or as a range.

An endpoint that is collecting captures with no route bound is a real failure mode: the sender gets a 202 for every delivery, so the vendor's dashboard reads published while nothing has reached your code. The Inbox shows a standing warning on any unbound endpoint that has captures, for exactly that reason.


What a Cell receives

A dispatch is the captured request, reissued at the bound Cell. The method, body and content-type are verbatim; the path is the endpoint's route plus any captured suffix. Captured request headers are forwarded except hop-by-hop headers and host, and the platform adds four of its own:

Header Value
x-tissue-event vesicle — the same slot pulse and synapse use
x-tissue-vesicle-endpoint the endpoint's name, e.g. soro
x-tissue-vesicle-id the capture id, e.g. cap_7b21… — stable across every retry and replay of that delivery
x-tissue-vesicle-attempt attempt number, starting at 1

Any x-tissue-* header the sender supplied is stripped before dispatch, so a caller cannot forge a platform header and your Cell can trust these four without checking anything.

A complete handler for the endpoint bound above:

[cell]
name = "santixs-new"
js   = "./cell.js"

[[bindings]]
type     = "c3"
binding  = "DB"
database = "posts"
const SCHEMA = `
  CREATE TABLE IF NOT EXISTS posts (
    capture_id  TEXT PRIMARY KEY,
    endpoint    TEXT NOT NULL,
    title       TEXT,
    body_html   TEXT,
    stored_at   TEXT NOT NULL
  )
`;

export default {
  async fetch(request, env) {
    if (request.headers.get("x-tissue-event") !== "vesicle") {
      return new Response("not found", { status: 404 });
    }

    const endpoint = request.headers.get("x-tissue-vesicle-endpoint");
    const captureId = request.headers.get("x-tissue-vesicle-id");
    const attempt = Number(request.headers.get("x-tissue-vesicle-attempt") ?? "1");

    let post;
    try {
      post = await request.json();
    } catch {
      // A 4xx is permanent: the delivery is marked rejected and never retried.
      return Response.json({ error: "expected JSON" }, { status: 400 });
    }

    await env.DB.exec(SCHEMA);
    // The capture id is stable across retries and replays, so keying on it makes
    // a repeated delivery overwrite its own row instead of adding a second one.
    await env.DB.prepare(
      `INSERT OR REPLACE INTO posts (capture_id, endpoint, title, body_html, stored_at)
       VALUES (?, ?, ?, ?, ?)`
    ).bind(captureId, endpoint, post.title ?? "", post.content_html ?? "",
           new Date().toISOString()).run();

    return Response.json({ stored: captureId, attempt }, { status: 200 });
  },
};

The delivery arrives on the bound route, so a Cell with its own router handles it like any other POST /api/posts and reads the four headers where it needs them.

Deliveries are at-least-once and unordered. A retry, a replay, or a sender that posts the same event twice all reach the handler again, and two captures that arrive seconds apart can be delivered out of order. x-tissue-vesicle-id is the key to write against when that matters.


Delivery, retries and dead-letter

Every capture has a state, and the state is what the Inbox, ribo vesicle list and the API all report:

State Meaning
captured stored, no route bound — waiting for a replay
delivered the Cell answered 2xx or 3xx
retrying the Cell answered 5xx, timed out, or could not be reached; another attempt is scheduled
rejected the Cell answered 4xx. Permanent — never retried
dead retries exhausted

A 4xx is never retried. A handler that rejects a payload will reject it identically in an hour, so the delivery lands as rejected and waits for you. It still shows in the dead-letter view, because a handler rejecting a whole day of a vendor's deliveries is the most common way an integration fails and the inbox would be useless if it hid that case.

A 5xx, a timeout, or a connection failure retries on a fixed backoff: 1m, 5m, 30m, 2h, 6h, 12h, 24h, 24h, then the capture goes dead. Nothing has to be configured for that to happen, and the schedule is long enough to cover a redeploy or an outage that runs for hours.

ack decides what the sender is told and when:

ack Behaviour
immediate the sender gets 202 as soon as the delivery is stored, and dispatch happens after. Use it for senders with tight timeouts
on-success the sender gets your Cell's response verbatim. The semantics a webhook sender expects, with durability added underneath

An unbound endpoint always answers 202, whatever ack says, because there is nothing to run yet.


Replaying a delivery

Replay is redelivery: the stored request is POSTed at a live Cell again. It is the way a captured, rejected, or dead delivery reaches a handler that is finally ready for it.

ribo vesicle replay cap_7b21                       # one, prints the Cell's response
ribo vesicle replay soro --all --since 2026-08-11  # a range
ribo vesicle replay soro --all --since 2026-08-11 --dry-run

A single replay returns what the handler did — status, headers, body, and how long it took — so you can read the result without going to look for a log:

{ "capture_id": "cap_7b21f4a90c3e5d81b6a2c4e0", "dispatched": true,
  "status": 201, "headers": { "content-type": "application/json" },
  "body": "{\"stored\":\"cap_7b21f4a90c3e5d81b6a2c4e0\",\"attempt\":1}", "duration_ms": 42 }

--dry-run reports what a range would send and dispatches nothing. Run it first on any range large enough that you would not want to be wrong about it.

Replaying at a Cell on your own machine

ribo vesicle replay cap_7b21 --to http://localhost:8080/26e9871aq7x2k/api/posts

--to runs from your machine. ribo fetches the capture over /v1 and performs the POST itself, so the target can be a runtime on localhost with no tunnel and no public URL pointed at your laptop.

The platform never posts to a URL you name. Doing this server-side would mean an authenticated account could aim the platform's own network at an arbitrary address, which is a request-forgery primitive, and no convenience is worth shipping one. --to implies --reveal (a redacted signature header would not verify at the far end) and therefore writes the same audit event a reveal does.


Verifying signatures

Set verify and the signature is checked before anything is stored:

Value Checks
none nothing (default)
hmac:KEY a generic HMAC signature header
stripe:KEY Stripe's signing scheme
github:KEY GitHub's x-hub-signature-256

KEY is the name of a vault binding on the Cell the endpoint is bound to. The secret stays in the vault and is never handed to your code, so a handler — including one an assistant wrote for you — never touches the signing key.

ribo vault set santixs-new SORO_SECRET      # set the value first
ribo deploy                                 # then deploy the Cell that declares it
ribo vesicle endpoint create soro --cell santixs-new --route /api/posts \
  --verify hmac:SORO_SECRET

Set the vault value before the deploy. A vault binding whose value is missing at deploy time stores a literal placeholder, and the Cell then uses that placeholder string as the secret — verification fails against every real delivery and nothing about the failure looks like a missing secret. Full detail in ribo.toml Reference and ribo vault.

A delivery whose signature does not verify is answered 401 and is not stored. It does not appear in the inbox, because it never became a capture.


Redaction

A capture is a third party's raw HTTP request, which routinely means it carries credentials: the bearer token you gave the vendor, or an API key sitting in the body. Every read surface redacts by default.

A name is masked when it is authorization, x-api-key, cookie or set-cookie, or when any word in it — splitting on - and _ — is signature, signatures, token, secret or password. Matching whole words rather than suffixes is what catches GitHub's x-hub-signature-256, where the algorithm suffix sits after the word. The same rule applies to JSON body keys, so access_token and client_secret are masked too:

$ ribo vesicle show cap_7b21
  {
    "headers": {
      "content-type": "application/json",
      "x-soro-signature": "«redacted»"
    },
    "redacted": true,
    ...
  }

Revealing is explicit: --reveal on the CLI, ?reveal=true on the API, include_secrets: true over MCP. Any of them returns the values intact, sets "redacted": false, and writes an audit_events row with the action vesicle.capture.reveal, so a reveal is always attributable afterwards.

Take particular care with a reveal over MCP. An unredacted capture goes straight into the model's context, and from there into the conversation history and the model provider's logs. If you need the real value of a credential, read it in your terminal, and rotate it at the vendor if it has been anywhere it should not have been.


Limits and retention

Limit Value
Body size 64 KiB. A larger delivery is answered 413 and is not stored
Ingest rate a per-endpoint rate limit; over it the sender gets 429
Retention 30 days from receipt, delivered or not

Stored captures count against the owning account's storage quota. Delete one early with ribo vesicle drop cap_7b21 (or DELETE /v1/vesicle/captures/{cid}, scope vesicle:delete).


Rotating and revoking

An endpoint id travels in a URL that vendors log and paste into support tickets, so treat it as a bearer credential and rotate it once it has been somewhere public.

ribo vesicle endpoint rotate soro      # new hk_, old one keeps working briefly
ribo vesicle endpoint revoke soro      # stop answering

Rotation issues a new hk_ id and keeps the old one capturing for a grace window of 7 days, with a warning recorded on every capture that still arrives on it. After the window the old id answers 404. The grace exists because a rotation the vendor has not applied yet would otherwise drop live events on the floor; use the window to update the vendor's settings, then check that captures have moved to the new id.

Revoking stops the URL immediately. Captures already stored survive to their normal 30-day retention, so revoking an endpoint does not destroy the record of what it received.


Scopes

Three scopes gate the API, and they behave like every other scope:

Scope Allows
vesicle:read list endpoints, list captures, read a capture (including a reveal)
vesicle:write create, bind, rebind, unbind, rotate, replay
vesicle:delete revoke an endpoint, drop a capture

A JWT session — ribo login or the dashboard — holds every scope implicitly. A tok_ API token carries an explicit allow-list, so an integration or an agent can be given vesicle:read alone and can then inspect everything an endpoint received while changing nothing:

ribo token create "webhook-reader" --scope vesicle:read

Ingest carries no scope at all. POST /hook/hk_… is not a /v1 route, takes no Authorization header, and the only thing it can do is deliver.


A complete integration

An endpoint's first job is finding out what a sender actually sends. This is the whole sequence, from an empty directory to a handler with the vendor's real deliveries behind it.

1. Create the endpoint. There is no Cell yet.

$ ribo vesicle endpoint create soro
  https://api.tissue.systems/hook/hk_9f3c8a1b...   (capture only — no route bound)

2. Paste the URL into the vendor and trigger one publish, or wait for their schedule to fire. The endpoint answers 202 and stores what arrives.

3. Watch it land.

$ ribo vesicle tail soro
  POST · 412 bytes · content-type: application/json
  { "title": "...", "content_html": "...", "meta_description": "...", "tags": [...] }

4. Read the delivery in full, and keep it as a fixture:

ribo vesicle show cap_7b21 > test/fixtures/publish.json

5. Write the handler against that payload — the field names in the fixture, not the ones a doc claims. The shape is above. Deploy it:

ribo deploy

6. Bind the endpoint to the Cell and the route the handler serves:

ribo vesicle endpoint bind soro --cell santixs-new --route /api/posts

From here every new delivery goes through to the Cell. The ones captured in steps 2 to 5 do not move on their own.

7. Replay the backlog, one delivery first so you can read the response:

$ ribo vesicle replay cap_7b21
  201  application/json  42ms
  {"stored":"cap_7b21f4a90c3e5d81b6a2c4e0","attempt":1}

$ ribo vesicle replay soro --all --since 2026-08-11 --dry-run
$ ribo vesicle replay soro --all --since 2026-08-11

The vendor was never told anything after step 2, and never needs to be again: renaming the Cell or moving the handler somewhere else is a bind on your side.


See also