Webhooks
Outbound, signed HTTP callbacks for Responses. Browser jobs run 60–420s; polling GET /api/v1/responses/:id is fine for v0. At production scale, create with background: true (HTTP 202), do other work, and let Banana Peel POST when the run lands — especially response.requires_action, which is time-sensitive MFA / human input.
Webhooks are per account (the API key's workspace), not global. Manage them here or in Console → Webhooks. This is distinct from Stripe's inbound billing webhook.
Register — field reference
POST/api/v1/webhooks (createWebhook)
Register an outbound webhook endpoint
Request body
| Field | Type | Req | Description |
|---|---|---|---|
| url | string (uri) | yes | Public HTTPS callback URL. Private, metadata, .internal, and localhost/loopback hosts are rejected in production (http://localhost is allowed only in local development). |
| events | string[] | — | Defaults to all event types when omitted. |
| secret | string | — | Optional signing secret. If omitted, Banana Peel mints `whsec_…` and returns it once. |
Response
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | wh_… |
| object | "banana_peel.webhook" | — | — |
| url | string | — | — |
| events | string[] | — | — |
| disabled | boolean | — | — |
| disabled_reason | string | — | — |
| consecutive_failures | integer | — | — |
| last_delivery_at | string (date-time) | — | — |
| last_http_status | integer | — | — |
| old_secret_expires_at | string (date-time) | — | On rotate: the previous secret remains valid until this timestamp (24h). Absent on create. |
| created_at | string (date-time) | — | — |
| updated_at | string (date-time) | — | — |
| secret | string | — | Signing secret (`whsec_…`) — returned once at create/rotate. |
| warning | string | — | — |
HTTP statuses
201— Created — includes `secret` once400— Invalid URL or events (`invalid_webhook_url` / `invalid_webhook_event`)401— Invalid API key
Generated from OpenAPI. Do not hand-maintain this table.
Endpoint object
Registered outbound webhook. The signing secret is write-only and never appears here.
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | wh_… |
| object | "banana_peel.webhook" | — | — |
| url | string | — | — |
| events | string[] | — | — |
| disabled | boolean | — | — |
| disabled_reason | string | — | — |
| consecutive_failures | integer | — | — |
| last_delivery_at | string (date-time) | — | — |
| last_http_status | integer | — | — |
| old_secret_expires_at | string (date-time) | — | When set, the previous signing secret still verifies until this instant (24h rotation grace). |
| created_at | string (date-time) | — | — |
| updated_at | string (date-time) | — | — |
Generated from OpenAPI. Do not hand-maintain this table.
Event payload
Outbound webhook POST body. Delivered at least once — store `id` and ignore duplicates. `data` is the same object as GET /v1/responses/{id}, redacted (no secrets). Delivery order is not guaranteed; `sequence` is monotonic per response so you can drop stale events. Treat the current GET /responses/:id state as authoritative.
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | yes | evt_… — canonical idempotency key. The same id may arrive more than once (retries / redelivery). Header X-Banana-Peel-Event-Id is a convenience copy. |
| object | "banana_peel.event" | yes | — |
| api_version | string | yes | Date-version of the event envelope (currently 2026-08-13). |
| type | string response.completed | response.failed | response.requires_action | response.cancelled | long_task.progress | long_task.interaction_required | long_task.completed | long_task.partial | long_task.failed | yes | — |
| sequence | integer | yes | Monotonic per response. Incremented once per event emitted for that response (shared across endpoints). Drop events with sequence less than the highest you have already applied for that response.id. |
| created_at | string (date-time) | yes | — |
| data | object | yes | Redacted response object. For requires_action, includes required_action.submit_input (kind + prompt) so the customer can POST /v1/responses/{id}/input — never the secret the user will type. On brain-routed runs, completed/failed events carry data.banana_peel.routing_summary — runner, strategy, reason code, one redacted sentence, per-attempt fault attribution, policy version, widening echo, and the path to the full decision card — so "why was it routed there" needs no second fetch. |
| data.id | string | — | — |
| data.object | "response" | — | — |
| data.created_at | integer | — | Unix seconds |
| data.status | string completed | failed | in_progress | requires_action | cancelled | — | — |
| data.required_action | object | — | Present when status is requires_action — owner secrets only (MFA/OTP/text/confirm). Never emitted for captcha / bot challenges. Visuals (live_view / screenshot / replay_url) are included when available. |
| data.required_action.type | "submit_input" | — | — |
| data.required_action.submit_input | object | — | — |
| data.incomplete_details | object | — | — |
| data.model | string | — | — |
| data.confidence | number | — | — |
| data.output | object[] | — | — |
| data.output_text | string | — | ALWAYS a plain string, on every runner: freeform answers verbatim; structured results (including output_schema-normalized ones) as pretty-printed JSON. The structured value itself is under banana_peel.output. Guaranteed never a character-indexed map ({"0":"T","1":"h",…}) and never double-encoded JSON-in-a-string — outputs are repaired/unwrapped at the canonical normalization choke point before the envelope is built or persisted. |
| data.usage | object | — | Approximate tokens (char/4); billing is USD under banana_peel.billing |
| data.usage.input_tokens | integer | — | — |
| data.usage.output_tokens | integer | — | — |
| data.usage.total_tokens | integer | — | — |
| data.metadata | object | — | — |
| data.error | object | — | — |
| data.error.code | string | — | — |
| data.error.message | string | — | — |
| data.error.remediation | string | — | — |
| data.banana_peel | object | — | Canonical banana_peel.run/v1 — superset over all runners. `runner` is the executed runner slug; `routing` is what you requested; `engine` is the execution backend when distinct. |
| data.banana_peel.schema | "banana_peel.run/v1" | — | — |
| data.banana_peel.id | string | — | — |
| data.banana_peel.status | string succeeded | failed | running | blocked | empty | needs_human | cancelled | — | — |
| data.banana_peel.progress | object | — | Live in-flight visibility (only while status is in_progress / requires_action): runner attempts so far, current Learning batch, and runner spend to date. Superseded by routing_attempts on the terminal object. |
| data.banana_peel.required_action | object | — | MFA/OTP pause mirror (prompt/kind only; never the secret). |
| data.banana_peel.model | string | — | — |
| data.banana_peel.routing | any | — | — |
| data.banana_peel.routing_note | string | — | — |
| data.banana_peel.ranked_runners | string[] | — | — |
| data.banana_peel.parent_id | string | — | Present on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs. |
| data.banana_peel.entity_id | string | — | Present on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step). |
| data.banana_peel.role | string enumeration | entity_fetch | verification | — | Role of a long-task sub-run within its parent. verification = a sampled spot-check confirming randomly chosen claimed entity ids exist. |
| data.banana_peel.routing_summary | object | — | Compact "why was it routed there" summary — present on every brain-routed run (routing: "smart"/category and forced-runner decisions the brain recorded; absent on pinned/chain/learning runs, which have no decision to explain). In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds per-attempt fault attribution, the policy version, and a budget-widening echo. The `summary` sentence is redacted before it leaves the API. The same object is embedded in response.completed / response.failed webhook payloads (data.banana_peel.routing_summary). For the full decision card (candidate EU table, site defense, widening steps + stop reasons) follow `explanation` — GET /v1/responses/{id}/explanation. |
| data.banana_peel.runner | string | — | Executed runner slug |
| data.banana_peel.engine | string | — | Execution backend behind the runner when distinct (e.g. browser-use-cloud) |
| data.banana_peel.url | string | — | — |
| data.banana_peel.output | any | — | Run result in canonical shape, identical across runners: a plain string for freeform tasks, or a structured object/array when the runner returned real JSON (or output_schema was requested). Normalized at a single choke point before persist + envelope build: char-exploded strings ({"0":"T","1":"h",…}) are reassembled and JSON-encoded strings unwrapped, so the shape never varies by runner. With an output_schema this is a HARD CONTRACT: a terminal success populates exactly the schema keys, or the run is failed with error.code schema_violation. |
| data.banana_peel.answer | string | — | GUARANTEED READ PATH (stable default envelope): best-effort distilled answer string, always present as a key. Runner outputs name their keys differently run to run (current_outstanding_balance vs result vs prose-in-summary) — `answer` is the stable projection, so clients without an output_schema never chase keys. Null exactly when answer_present is false. For legitimately empty results the answer states the empty result (e.g. the "No Order History Found" evidence); for download tasks it lists the delivered files. |
| data.banana_peel.answer_present | boolean | — | Delivery-verdict bit: was the REQUESTED ANSWER actually delivered? True on every genuine success — including a legitimate empty/zero result ("0 orders" IS the answer; empty-result ≠ failure). False on failed/blocked runs and on hollow completions (output that only narrates login/click actions without the requested value — those are status failed, never billed-and-claimed-successful). Meaningful on terminal states; false while still running. |
| data.banana_peel.evidence | object | — | Evidence trail backing `answer`: verdict reason, final URL, artifact names. |
| data.banana_peel.error | object | — | — |
| data.banana_peel.quality | object | — | Delivery verdict from the honesty layer (deterministic checks + QA grader): did the run deliver the requested answer? Terminal `status` follows this verdict — not the runner’s mechanical exit code — and the routing scoreboard/brain learn from the same verdict-based labels. |
| data.banana_peel.deliverables | object | — | Delivery verdict for file-deliverable tasks: claimed downloads vs files actually captured as retrievable artifacts. Null for text-only tasks. A run that claims downloads but delivers zero captured files is demoted to failed (verdict not_delivered) — in-session download URLs expire with the browser session and are not deliverables. In structured output, `download_succeeded` is renamed `download_triggered` (the in-session click) with a sibling `file_delivered` boolean (actual capture). |
| data.banana_peel.block_reason | string | — | — |
| data.banana_peel.timing_ms | number | — | — |
| data.banana_peel.cost | object | — | — |
| data.banana_peel.normalization | object | — | Output normalization echo — present whenever normalization was requested. `applied: true` means the reshape pass executed (and the +$0.10 add-on was billed); `applied: false` includes a short `reason` and `addon_usd: 0` (never charged for a pass that did not run, and never charged when the output_schema contract was violated — see error.code schema_violation). |
| data.banana_peel.custody | object | — | Custody enforcement echo — present whenever a credential custody policy applied to this run (vault credential and/or request `custody`). The runner candidate set was hard-filtered to `allowed_runners` BEFORE routing; no fallback, race, wave, or learning probe left that set. Persisted on the run record for audit. |
| data.banana_peel.session | object | — | Metadata about the browser this run used (live view, replay, CDP connect). A record of the run’s browser — not a standalone session you manage. Unused fields are null. |
| data.banana_peel.live_view | string | — | — |
| data.banana_peel.replay_url | string | — | — |
| data.banana_peel.screenshot | string | — | — |
| data.banana_peel.screenshot_unavailable_reason | string | — | — |
| data.banana_peel.artifacts | any[] | — | — |
| data.banana_peel.steps | object[] | — | — |
| data.banana_peel.fallback_from | string[] | — | — |
| data.banana_peel.act_script | array | — | — |
| data.banana_peel.created_at | string (date-time) | — | — |
| data.banana_peel.finished_at | string (date-time) | — | — |
Generated from OpenAPI. Do not hand-maintain this table.
Production recipe: 202 + webhook
# 1. Register the endpoint (once)
curl -s "$BANANA_PEEL_BASE/api/v1/webhooks" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhooks/bananapeel"}'
# 2. Create in the background — do other work; we POST when the run lands
curl -s "$BANANA_PEEL_BASE/api/v1/responses" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: respond-async" \
-d '{
"model": "banana-peel",
"input": "Extract the title from https://example.com",
"routing": "smart",
"background": true
}'
# → 202 { "id": "resp_…", "status": "in_progress" }
# 3. Your server receives:
# POST https://example.com/webhooks/bananapeel
# X-Banana-Peel-Signature: t=<unix>,v1=<hmac_sha256_hex>
# X-Banana-Peel-Event-Id: evt_…
# {
# "id": "evt_…",
# "object": "banana_peel.event",
# "api_version": "2026-08-13",
# "type": "response.completed",
# "sequence": 1,
# "created_at": "…",
# "data": { /* GET /responses/:id */ }
# }Register
POST /api/v1/webhooks — url required (public https; http://localhost is allowed only in local development and rejected in production). Optional events (defaults to every event type — the four response.* events and the five long_task.* events). Optional secret; if omitted, Banana Peel mints whsec_… and returns it once. List/get never include the secret. Rotate with POST /api/v1/webhooks/:id/rotate — the previous secret stays valid for 24 hours (old_secret_expires_at).
curl -s "$BANANA_PEEL_BASE/api/v1/webhooks" \
-H "Authorization: Bearer $BANANA_PEEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/bananapeel",
"events": [
"response.completed",
"response.failed",
"response.requires_action",
"response.cancelled"
]
}'
# → 201
# {
# "id": "wh_…",
# "object": "banana_peel.webhook",
# "url": "https://example.com/webhooks/bananapeel",
# "events": [ "response.completed", … ],
# "disabled": false,
# "secret": "whsec_…", # shown once
# "created_at": "2026-08-13T15:00:00.000Z"
# }GET /api/v1/webhooks/GET /api/v1/webhooks/:idPATCH /api/v1/webhooks/:id— url, events, enabledDELETE /api/v1/webhooks/:idGET /api/v1/webhooks/:id/deliveries/GET …/deliveries/:delivery_id/POST …/deliveries/:delivery_id/retry- Ids from another account 404 (same as vault credentials).
- At most 25 endpoints per account (
webhook_limit).
URL requirements (SSRF protection)
Endpoint URLs are validated twice: once at register/update time and again at every delivery, with DNS re-resolved on each attempt so a record that later points somewhere private stops receiving traffic. Failing URLs are rejected with invalid_webhook_url; failing deliveries record the reason on the delivery row.
https://required.http://localhost/127.0.0.1is a development-only carve-out — in production, localhost and loopback URLs are rejected.- Hosts must resolve to public IPs. Private and reserved ranges (RFC1918, CGNAT, link-local), cloud-metadata addresses (169.254.169.254,
metadata.google.internal),*.internal/*.localnames, numeric (dword) hosts, and URLs with embedded credentials are rejected. - Self-hosting? Set
BP_ALLOW_LOCAL_WEBHOOKS=1to opt out of the production localhost block (never set this on an internet-facing deployment).
Events
response.completed— terminal successresponse.failed— failed / blocked / hard-TTLrun_timeoutresponse.cancelled—POST /api/v1/responses/:id/cancelresponse.requires_action— MFA / human input opened. Payload includesdata.idanddata.required_action(kind + prompt) so you canPOST /api/v1/responses/:id/input. The secret the user will type is never included. May fire more than once per run (multiple MFA rounds).
Long tasks emitted their own event family. Multi-entity orchestration is disabled, so long_task.* events no longer fire — the types below remain valid subscription values (existing configurations keep validating) and are documented for reading historical deliveries. For all long_task.* events, data is the long-task object from GET /api/v1/long-tasks/:id (per-entity table, counts, gaps, budget, events) — same envelope, signature, and retry machinery as response.*; the long-task id takes the response-id slot in the envelope sequence.
long_task.progress— fires per entity settle (delivered / retry / unfetchable / exhausted) and once when the manifest verifies. Repeating by design: one delivery per (entity, outcome), not one per task.long_task.interaction_required— a sub-task hit MFA and the task paused.data.interaction.run_idis the holding child run — answer viaPOST /api/v1/responses/:run_id/input. Fires once per holding run.long_task.completed/long_task.partial/long_task.failed— the terminal trio, once each per task.partialis first-class: some entities delivered;data.gapslists exactly what did not, and why. There is no separate cancellation event type: a cancelled task arrives aslong_task.failedwithdata.status: "cancelled"— readdata.status, not just the event type, to distinguish.
event.id (the idfield in the JSON body — canonical) and ignore events you've already processed. The X-Banana-Peel-Event-Id header is a convenience copy of the same value.if (alreadyProcessed(event.id)) return res.sendStatus(200);
enqueue(event); // process async
markProcessed(event.id);
return res.sendStatus(200); // Banana Peel waits up to 10 secondsGET /api/v1/responses/:id) as authoritative. Each envelope includes a per-response monotonic sequence so you can drop stale events (for example requires_action #2 arriving after completed).For response.* events, data is the same object as GET /api/v1/responses/:id, redacted (no secrets, no inline image bytes); long_task.* payloads are redacted the same way. Delivery is asynchronous — the run response is never delayed waiting on your endpoint. On brain-routed runs, response.completed / response.failed payloads therefore include data.banana_peel.routing_summary— runner, strategy, reason code, one redacted plain-words sentence, per-attempt fault attribution, policy version, widening echo, and the path to the full decision card — so "why was it routed there" arrives with the event instead of needing a second fetch. See Routing explanations.
Signature
Stripe-style HMAC. Headers: X-Banana-Peel-Signature: t=<unix>,v1=<hmac_sha256_hex> over {t}.{raw_body} using the endpoint secret. During the 24h rotation grace the header includes both signatures: t=…,v1=<new>,v1=<old> — verify against any v1. Also X-Banana-Peel-Event-Id, User-Agent: BananaPeel-Webhook/1.0. Reject deliveries older than ~5 minutes. Always verify against the raw body.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyBananaPeelSignature(secret, rawBody, header) {
const v1s = [];
let t;
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
const val = part.slice(eq + 1);
if (k === 't') t = val;
else if (k === 'v1') v1s.push(val);
}
if (!t || !v1s.length) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
return v1s.some((v1) => {
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
});
}
// Express: app.post('/webhooks/bananapeel', express.raw({ type: 'application/json' }), (req, res) => {
// const raw = req.body.toString('utf8');
// if (!verifyBananaPeelSignature(process.env.BP_WEBHOOK_SECRET, raw, req.get('x-banana-peel-signature'))) {
// return res.status(400).send('invalid signature');
// }
// const event = JSON.parse(raw);
// if (alreadyProcessed(event.id)) return res.sendStatus(200);
// enqueue(event); // do not wait on work
// markProcessed(event.id);
// res.sendStatus(200); // Banana Peel waits up to 10 seconds
// });Acknowledge quickly
Banana Peel waits up to 10 seconds for a response. Verify the signature, enqueue the event, return 2xx. Do the real work asynchronously. Non-2xx (or a timeout) schedules a retry.
Retries
Success is HTTP 2xx. Terminal events (completed / failed / cancelled) retry with exponential backoff over ~24 hours: immediate, 5s, 30s, 2m, 10m, 30m, 2h, 8h, 24h. Due attempts are persisted (next_attempt_at) and swept by GET/POST /api/cron/webhooks (Bearer / x-cron-secret matching BP_CRON_SECRET) — also piggybacked on /api/cron/expire-artifacts. Cloud Run cannot sleep 24h in-process. If cron is not configured, listing webhooks still lazy-sweeps due retries for that account.
response.requires_action uses a short schedule (immediate, 5s, 15s, 30s, 1m, 2m) and stops retrying once the underlying MFA interaction is answered or expired — delivering it after the window is useless.
Duplicate terminal writes do not double-notify (one event per webhook + response + type; repeating types add a discriminator — MFA rounds the interaction id, long_task.progress the entity + outcome, long_task.interaction_required the holding run id). After 20 consecutive exhausted deliveries the endpoint is auto-disabled — re-enable with PATCH … { "enabled": true } or Console → Webhooks. Manual redelivery: POST /api/v1/webhooks/:id/deliveries/:delivery_id/retry (same event.id).
Delivery object
One attempt-history row for an outbound event. Payload/response previews are capped; secrets are never stored.
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | — | whdel_… |
| object | "banana_peel.webhook_delivery" | — | — |
| webhook_id | string | — | — |
| event_id | string | — | Frozen evt_… — redelivery reuses this id. |
| type | string | — | — |
| response_id | string | — | — |
| sequence | integer | — | — |
| status | string pending | delivered | failed | — | — |
| attempt | integer | — | — |
| next_attempt_at | string (date-time) | — | — |
| last_http_status | integer | — | — |
| last_error | string | — | — |
| last_latency_ms | integer | — | — |
| response_body_preview | string | — | First 512 bytes of the customer endpoint’s response body. |
| request_headers | object | — | Sent headers except the HMAC signature. |
| redelivery_count | integer | — | — |
| payload_preview | string | — | Truncated frozen event JSON (GET one only). |
| created_at | string (date-time) | — | — |
| updated_at | string (date-time) | — | — |
Generated from OpenAPI. Do not hand-maintain this table.
Polling still works. Prefer webhooks when you cannot hold a 60–420s socket or need MFA without spinning GET. See Create a response.