Reference

API reference

The machine entrance's whole HTTP surface. Base URL: https://api.bubblio.dev. The same contract, typed: download the OpenAPI 3.1 spec.

Authentication, four flavors

LabelWhat it is
noneThe public door surface (/agent/**). Agents call with no credential; every rate ceiling is published in the manifest.
bbl_ keyAuthorization: Bearer bbl_… — the platform's own server calling /v1/**. Never valid from a browser.
signed pushAPI key plus X-Bubblio-Signature-V2/X-Bubblio-Timestamp — HMAC-SHA256 of ${timestamp}.${rawBody} with the platform's webhook secret, ±5 min. The key alone can never move a receipt.
consent tokenBearer <JWT> from POST /approvals/session — the human's passkey session on the approvals surface.

Two properties shape many responses: uniform misses (unknown, malformed, foreign, disabled, and migration-lagged resources answer the same 404 body per surface, so a prober can't tell closed from nonexistent — on the push endpoints even a failed signature answers that 404) and calm errors (one plain sentence; machine codes ride a code field — branch on that, never on message text).

Discovery

GET/agent/directorynone
Every listed door plus the hub block: { hub, feed, doors: [{ doorId, label, handle, blurb?, manifest, mcp, paid_actions? }] }. Owners may run unlisted doors — absence proves nothing. Transient read failures degrade to an honest empty doors list, never a 500.
GET/agent/feed.jsonnone
The directory as ACP-compatible product rows for crawlers without MCP — one row per listed visible action, required ACP fields plus bubblio_* extensions; divergences declared in the body's mapping notes. is_eligible_checkout is false on every row — transacting is MCP quote → confirm. Cached per ttl_seconds; per-caller 429 with Retry-After.
GET/agent/hubnone
The hub manifest — stable by construction (no DB read): capabilities, endpoints, the six tools, find's own published limits.
GET/agent/hub/llms.txtnone
Plain text for LLM crawlers: the one-connector path, tools, a bare-POST example.
GET/agent/{doorId}none
A door's manifest: label, capabilities (act only while ≥1 action is visible), endpoints, pricing + payment when actions are priced (publishable key only — facts, never credentials), and every rate ceiling. 404 {"error": "No agent door here."} for everything that isn't an open door.
GET/agent/{doorId}/llms.txtnone
The door's plain-text signpost: tools, prices, caps, connect instructions.

MCP endpoints

Minimal, hand-rolled, stateless Streamable-HTTP servers: POST one JSON-RPC 2.0 message per request, receive one application/json response — no SSE, no session ids. Methods: initialize, ping, tools/list, tools/call; notifications (no id) are acknowledged 202. Tool failures come back as isError results the calling model can read; JSON-RPC error objects are reserved for malformed requests, unknown methods, unknown tools. Bodies cap at 64KB (413); tripped ceilings answer 429 with the real limit in the message.

one message per POSTtext
POST /agent/{doorId}/mcp
{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"quote","arguments":{"tool":"generate_image","args":{"prompt":"…"}}}}

→ 200 {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{ …the quote JSON… }"}]}}
POST/agent/hub/mcpnone
The hub: the five door tools with a door argument (handle or door id) resolved through the unchanged per-door machinery, plus find { query }. Custody, approvals, mandates, payments, and rate buckets all scope to the resolved door. GET answers 405 — there is nothing to GET on a stateless endpoint.
POST/agent/{doorId}/mcpnone
A door's own endpoint: about/ask always; list_actions/quote/confirm while actions are visible. The action lifecycle and result shapes are the actions guide; quotes needing a human are the consent guide.

Approvals (the consent pages' API)

GET/approvals/a/{approvalId}none
The approval's facts — status, kind (action/connect), platform, pinned action + args + amount, the agent's presented vs verified identity, mandate context. Renders facts and writes nothing — prefetchers are inert. Uniform 404: “No approval here.”
POST/approvals/a/{approvalId}/optionsnone
Mints the WebAuthn ceremony options. mode: 'auth' (returning passkey) or 'register' (the first Face ID — enrollment IS the approval); both carry the same deterministic challenge: the SHA-256 of this approval's canonical terms. 409 when already decided/expired, or code: not_connected while a connect grant awaits its key.
POST/approvals/a/{approvalId}/approvenone
Verifies the WebAuthn response against the terms-derived challenge and flips the approval, winner-only. mandate: true additionally mints the offered standing mandate — one signature, indivisible. Idempotent: { ok, status: "approved", already: true } on repeats.
POST/approvals/a/{approvalId}/declinenone
Declines — no ceremony needed; declining must always be easy. Idempotent.
POST/approvals/session/optionsnone
A random single-use passkey sign-in challenge for the management surface (5-minute TTL) — authentication, not consent-over-terms.
POST/approvals/sessionnone
Verifies the sign-in assertion → { token }, the Bearer for the endpoints below.
GET/approvals/mineconsent token
Everything this passkey decided — connections, approval receipts, standing mandates with live remaining budgets. The out-of-band kill-switch surface: works without any agent nearby.
POST/approvals/mine/{approvalId}/revokeconsent token
Revokes an approved connection, winner-only, effective at the very next quote/confirm (the door re-reads live, fail-closed). Idempotent.
POST/approvals/mine/mandates/{mandateId}/revokeconsent token
Revokes a standing mandate (mnd_…) — immediate at the door: the next quote skips it, and a confirm on a covered quote re-reads it pre-flight and refuses without consuming.

Managed agent keys

POST/v1/agent-keysbbl_ key
Mint. Body: { ctx, label?, externalUserId?, userHint?, max_active_per_user? } ctx is the platform-signed user JWT (shape-checked only; Bubblio can neither verify nor forge it). 201 answers the raw bak_ key exactly once; at cap: 409 code: key_cap_reached; mint ceiling: 429 (default 30/hour).
GET/v1/agent-keys?externalUserId=…bbl_ key
List — display metadata only; never hashes, never ctx, never the key.
DELETE/v1/agent-keys/{keyId}bbl_ key
Revoke — idempotent, customer-scoped; the first revoke's timestamp survives repeats, and in-flight quotes made with the key refuse to confirm from this moment.
POST/v1/agent-keys/connect-grantbbl_ key
The phone-connect forward: { grantId, key, id } — the raw key must hash to the named row (proof the caller holds the very key it connects); the write stamps the waiting quote and approval, then flips the grant last, winner-only. Idempotent for the same key; a different key against a granted grant is a 409 — one grant, one key, forever.

Action-registry sync

POST/v1/agent-door/actions/syncbbl_ key
The SDK's door.sync. Shapes only, never capability: synced actions arrive with policy off; suggestions are displayed, never applied — which is exactly why API-key auth is allowed here. Idempotent; undeclared actions are orphaned (never deleted); destructive-looking names sync anyway with the suggestion dropped and a warning returned. connectPath sets the phone-connect page; payments sets the rails profile (publishable key only — sk_/rk_ refused by name). Answers { synced, orphaned, unchanged, pendingDecision, warnings }.

Signed platform pushes

POST/v1/agent-door/results/{quoteId}signed push
Push a late result onto a quote — the >20s escape hatch (the SDK's pushDoorResult; automatic in createBubblioToolRoute). Only unreachable → ok exists; the upgrade is append-shaped and the signed body must name this same quote. A pushed payment summary must match the pin exactly. Results cap at 32KB (refused, not truncated). 409: already_resolved (benign), not_confirmed, final_outcome.
POST/v1/agent-door/paid/{quoteId}signed push
Mark a payment-link quote settled — called from the platform's checkout.session.completed webhook (the SDK's completeBubblioCheckout / resumePaidQuote). Refused when args_hash or amount mismatches the pin (amount_mismatch, args_mismatch) — the money must buy exactly the pinned call, and every refund-worthy refusal means the payment bought nothing (the SDK auto-refunds it). 409: expired, already_settled, not_payable, conflict.

The outbound half: the signed callback

What Bubblio POSTs to your callbackUrl — the contract createBubblioToolRoute verifies for you. Signed twice for a deprecation window: verify the replay-protected X-Bubblio-Signature-V2; the legacy body-only signature is replayable and refused by SDK ≥0.9. Delivered at most once per quote; Bubblio waits min(timeoutSeconds ?? 10, 20) seconds, then records unreachable until a signed results push resolves it. Body fields — tool, args, agent, ctx, idempotencyKey, callbackTimeoutSeconds, payment/payment_settled/paymentLinkRequest — are documented in the actions guide and the payments guide; the body is extensible, so never validate it with a closed schema. Answer 200 with JSON (outcome ok); any non-2xx finalizes the receipt as platform_rejected. For a paymentLinkRequest, answer { url, ref }.