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

FieldTypeReqDescription
urlstring (uri)yesPublic HTTPS callback URL. Private, metadata, .internal, and localhost/loopback hosts are rejected in production (http://localhost is allowed only in local development).
eventsstring[]Defaults to all event types when omitted.
secretstringOptional signing secret. If omitted, Banana Peel mints `whsec_…` and returns it once.

Response

FieldTypeReqDescription
idstringwh_…
object"banana_peel.webhook"
urlstring
eventsstring[]
disabledboolean
disabled_reasonstring
consecutive_failuresinteger
last_delivery_atstring (date-time)
last_http_statusinteger
old_secret_expires_atstring (date-time)On rotate: the previous secret remains valid until this timestamp (24h). Absent on create.
created_atstring (date-time)
updated_atstring (date-time)
secretstringSigning secret (`whsec_…`) — returned once at create/rotate.
warningstring

HTTP statuses

  • 201 — Created — includes `secret` once
  • 400 — 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.

FieldTypeReqDescription
idstringwh_…
object"banana_peel.webhook"
urlstring
eventsstring[]
disabledboolean
disabled_reasonstring
consecutive_failuresinteger
last_delivery_atstring (date-time)
last_http_statusinteger
old_secret_expires_atstring (date-time)When set, the previous signing secret still verifies until this instant (24h rotation grace).
created_atstring (date-time)
updated_atstring (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.

FieldTypeReqDescription
idstringyesevt_… — 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_versionstringyesDate-version of the event envelope (currently 2026-08-13).
typestring
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
sequenceintegeryesMonotonic 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_atstring (date-time)yes
dataobjectyesRedacted 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.idstring
data.object"response"
data.created_atintegerUnix seconds
data.statusstring
completed | failed | in_progress | requires_action | cancelled
data.required_actionobjectPresent 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_inputobject
data.incomplete_detailsobject
data.modelstring
data.confidencenumber
data.outputobject[]
data.output_textstringALWAYS 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.usageobjectApproximate tokens (char/4); billing is USD under banana_peel.billing
data.usage.input_tokensinteger
data.usage.output_tokensinteger
data.usage.total_tokensinteger
data.metadataobject
data.errorobject
data.error.codestring
data.error.messagestring
data.error.remediationstring
data.banana_peelobjectCanonical 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.idstring
data.banana_peel.statusstring
succeeded | failed | running | blocked | empty | needs_human | cancelled
data.banana_peel.progressobjectLive 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_actionobjectMFA/OTP pause mirror (prompt/kind only; never the secret).
data.banana_peel.modelstring
data.banana_peel.routingany
data.banana_peel.routing_notestring
data.banana_peel.ranked_runnersstring[]
data.banana_peel.parent_idstringPresent on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs.
data.banana_peel.entity_idstringPresent on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step).
data.banana_peel.rolestring
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_summaryobjectCompact "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.runnerstringExecuted runner slug
data.banana_peel.enginestringExecution backend behind the runner when distinct (e.g. browser-use-cloud)
data.banana_peel.urlstring
data.banana_peel.outputanyRun 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.answerstringGUARANTEED 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_presentbooleanDelivery-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.evidenceobjectEvidence trail backing `answer`: verdict reason, final URL, artifact names.
data.banana_peel.errorobject
data.banana_peel.qualityobjectDelivery 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.deliverablesobjectDelivery 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_reasonstring
data.banana_peel.timing_msnumber
data.banana_peel.costobject
data.banana_peel.normalizationobjectOutput 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.custodyobjectCustody 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.sessionobjectMetadata 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_viewstring
data.banana_peel.replay_urlstring
data.banana_peel.screenshotstring
data.banana_peel.screenshot_unavailable_reasonstring
data.banana_peel.artifactsany[]
data.banana_peel.stepsobject[]
data.banana_peel.fallback_fromstring[]
data.banana_peel.act_scriptarray
data.banana_peel.created_atstring (date-time)
data.banana_peel.finished_atstring (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/webhooksurl 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/:id
  • PATCH /api/v1/webhooks/:id — url, events, enabled
  • DELETE /api/v1/webhooks/:id
  • GET /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.1 is 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 / *.local names, numeric (dword) hosts, and URLs with embedded credentials are rejected.
  • Self-hosting? Set BP_ALLOW_LOCAL_WEBHOOKS=1 to opt out of the production localhost block (never set this on an internet-facing deployment).

Events

  • response.completed — terminal success
  • response.failed — failed / blocked / hard-TTL run_timeout
  • response.cancelled POST /api/v1/responses/:id/cancel
  • response.requires_action — MFA / human input opened. Payload includes data.id and data.required_action (kind + prompt) so you can POST /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_id is the holding child run — answer via POST /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. partial is first-class: some entities delivered; data.gaps lists exactly what did not, and why. There is no separate cancellation event type: a cancelled task arrives as long_task.failed with data.status: "cancelled" — read data.status, not just the event type, to distinguish.
Delivery semantics — Webhooks are delivered at least once. Your endpoint may receive the same event more than once. Store 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 seconds
Ordering — Delivery order is not guaranteed. Always treat the current response state (GET /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.

FieldTypeReqDescription
idstringwhdel_…
object"banana_peel.webhook_delivery"
webhook_idstring
event_idstringFrozen evt_… — redelivery reuses this id.
typestring
response_idstring
sequenceinteger
statusstring
pending | delivered | failed
attemptinteger
next_attempt_atstring (date-time)
last_http_statusinteger
last_errorstring
last_latency_msinteger
response_body_previewstringFirst 512 bytes of the customer endpoint’s response body.
request_headersobjectSent headers except the HMAC signature.
redelivery_countinteger
payload_previewstringTruncated frozen event JSON (GET one only).
created_atstring (date-time)
updated_atstring (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.

Command Palette

Search for a command to run...