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
- Vesicle API Reference: the
/v1/vesicle/*routes and their scopes - ribo CLI Reference: every
ribo vesiclecommand and flag - ribo.toml Reference: vault bindings, used by
verify - c3 Overview: where a handler usually puts what it receives
- MCP Server: the
vesicle_*tools an assistant uses