# Guide to Browserbase Webhooks: Features and Best Practices

Browserbase runs headless browsers for AI agents and automation, and Browserbase Functions let you deploy code that runs against those browsers. Its webhooks report the lifecycle of that code: an invocation is queued, starts, completes or fails, and a build of your Function starts, completes or fails. Instead of holding a request open or polling until an invocation finishes, you let Browserbase tell you.

This guide covers how Browserbase webhooks are registered and signed, the events they carry, how retries, ordering and secret rotation work, and the best practices for running a Browserbase receiver in production.

## What are Browserbase webhooks?

A Browserbase webhook is an HTTPS endpoint registered against a project, subscribed to one or more event types. The API key you use decides which project owns it. You can manage webhooks in **Settings** in the Browserbase dashboard or with the SDK, and both act on the same webhooks.

Deliveries follow the Standard Webhooks specification. Every `POST` carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and the signature is an HMAC-SHA256 over the ID, the timestamp and the raw body, keyed with your webhook's `whsec_` secret. Browserbase's advice is to verify with a Standard Webhooks library rather than writing the HMAC yourself, and any library that implements the specification works.

## Browserbase webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings** in the dashboard, or the SDK and API (`bb.webhooks.create`) |
| Scope | One project. Each endpoint URL can be registered once per project |
| Subscription model | A list of event types. Unknown types are rejected at registration |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, sent as `v1,<base64>` |
| Secret | One per webhook, prefixed `whsec_`, returned only at creation and on rotation |
| Rotation | The previous secret keeps verifying for 24 hours unless you pass `revokeImmediately` |
| Replay window | None stated by Browserbase |
| Response window | Any `2xx` within 15 seconds. The body is ignored |
| Retries | Immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Auto-disable | After five days of continuous failures |
| Ordering | Not guaranteed |
| Source IPs | Not published |

## Common events

All seven event types come from Functions. An invocation is one run of a Function, and a build is a publish of your Function code.

| Event | Fires when |
| --- | --- |
| `functions.invocations.pending` | Browserbase accepts and queues an invocation |
| `functions.invocations.running` | An invocation starts executing |
| `functions.invocations.completed` | An invocation finishes successfully |
| `functions.invocations.failed` | An invocation fails |
| `functions.builds.running` | A build starts |
| `functions.builds.completed` | A build finishes successfully |
| `functions.builds.failed` | A build fails |

Every body is the same envelope:

```json
{
  "id": "85e6ff2d-91de-468a-867c-e0d9e4587063",
  "type": "functions.invocations.completed",
  "resourceId": "a3f1c2d4-7b90-4e51-9c8a-2f6d1e0b4c73",
  "data": {}
}

```

`id` identifies the event and stays the same across retries, `type` is the event type, `resourceId` is the invocation or build the event concerns, and `data` holds the type-specific fields. Browserbase doesn't document what `data` contains for each type, and says the envelope keys are additive: it "may add keys but will not rename or remove them".

## Setting up Browserbase webhooks

Create a webhook with the SDK and store the secret straight away, because no endpoint returns it later:

```javascript
import { Browserbase } from "@browserbasehq/sdk";

const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });

const webhook = await bb.webhooks.create({
  endpoint: "https://example.com/webhooks/browserbase",
  eventTypes: ["functions.invocations.completed", "functions.invocations.failed"],
});

console.log(webhook.secret); // store as BROWSERBASE_WEBHOOK_SECRET

```

Creating a webhook in **Settings** shows the secret once in the same way. The endpoint must be `https://` with a real host, and registering the same URL twice in one project returns a conflict.

To change the subscription, call `bb.webhooks.update` with `eventTypes`. The list replaces the existing one rather than adding to it, so include every type you want to keep.

## Securing Browserbase webhooks

Read the three headers, base64-decode the part of your secret after `whsec_`, compute an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, base64-encode it, and compare it in constant time with each `v1,` entry in `webhook-signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyBrowserbaseSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret) return false;

  // Optional: Browserbase states no tolerance of its own. If you want a freshness check, e.g.
  // if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', key).update(signedContent).digest();

  // During a rotation the header can carry one entry per active secret.
  return signatureHeader.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/browserbase', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.BROWSERBASE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyBrowserbaseSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.type, then process asynchronously.
  res.sendStatus(204);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_browserbase_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header and secret):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode(), expected):
            return True
    return False

```

Browserbase's own examples use the `standardwebhooks` package (npm and PyPI): `new Webhook(secret).verify(rawBody, headers)` in Node and `Webhook(secret).verify(body, headers)` in Python. The library also applies a timestamp tolerance, which Browserbase leaves to it rather than defining one.

To check your verifier, use the secret `whsec_YnJvd3NlcmJhc2UtdGVzdC1zZWNyZXQta2V5`, `webhook-id` `msg_2x2zwLOcWaRTzsLK8KtbQt1FTk9`, `webhook-timestamp` `1761039000` and this exact body:

```
{"id":"85e6ff2d-91de-468a-867c-e0d9e4587063","type":"functions.invocations.completed","resourceId":"a3f1c2d4-7b90-4e51-9c8a-2f6d1e0b4c73","data":{}}

```

The `webhook-signature` should be:

```
v1,U2mpA1mPoSb5yR/HVW6xvFcOGUp25V2H4M54go7G/SE=

```

> **Make Browserbase webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Standard Webhooks signature on every Browserbase delivery, answers inside the 15-second window, and durably queues each invocation and build event before your service sees it.

## Browserbase webhook limitations and pain points

### Events for one invocation arrive out of order

**The Problem:** A `completed` event arrives before the `running` event for the same invocation, and your status tracking moves backwards.

**Why It Happens:** Browserbase says it "doesn't order events for one resource". Retries make this more likely, because a delayed `running` can arrive after the invocation has finished.

**Workarounds:**

* Treat `completed` and `failed` as terminal, and ignore non-terminal events for a resource that has already reached one.
* Key state on `resourceId`, not on arrival order.

**How Hookdeck Can Help:** Hookdeck filters can send terminal and non-terminal events to different destinations, so a late `running` event doesn't overwrite a final state.

### The secret is shown once

**The Problem:** You lose the signing secret and can't get it back.

**Why It Happens:** Browserbase returns the secret only from create and rotate. List and get calls never return it.

**Workarounds:**

* Store the secret in a secret manager as soon as you create the webhook.
* If it's lost, rotate the secret to get a new one.

**How Hookdeck Can Help:** Store the secret once on a Hookdeck source and let Hookdeck verify every delivery, so the secret lives in one place rather than in each service that consumes Browserbase events.

### Rotation needs a careful order

**The Problem:** Deliveries fail verification right after a rotation, or a second rotation fails.

**Why It Happens:** Rotation issues a new secret and keeps the old one verifying for 24 hours, so signatures for both can be in flight. Only a limited number of rotated secrets can be inside their 24-hour window at once, and rotating again before a window closes fails without changing anything.

**Workarounds:**

* Rotate, deploy the new secret alongside the old one, confirm traffic verifies, then drop the old secret, which is the order Browserbase recommends.
* Accept a request if any `v1,` entry matches.
* If a secret leaked, pass `revokeImmediately` so the old one stops working at once.

**How Hookdeck Can Help:** Update the secret on your Hookdeck source after rotating. Deliveries signed with both secrets keep verifying with either one until the old secret expires.

### Five days of failures switch the endpoint off

**The Problem:** Events stop arriving and nothing in your logs explains it.

**Why It Happens:** After eight attempts over the retry schedule, Browserbase marks a message as failed. An endpoint that keeps failing for five days is disabled. Redirects count as failures too.

**Workarounds:**

* Register the final URL, not one that redirects.
* Return a `2xx` as soon as the event is stored.
* Check the delivery history in **Settings** when events stop.

**How Hookdeck Can Help:** Hookdeck accepts each delivery immediately and stores it, so your downtime doesn't use up Browserbase's retries, and you can replay the stored events once your service recovers.

## Best practices

### Verify against the raw body

Browserbase warns that "any reserialization changes the bytes and breaks the signature". Use `express.raw()`, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI.

### Deduplicate on the envelope `id`

Browserbase retries when it doesn't get a `2xx` in 15 seconds, including when your response was simply late. Store `id` and skip events you've already processed. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Parse defensively

Ignore envelope keys you don't recognise, and don't assume a shape for `data` until you've captured real deliveries for each type you handle.

### Acknowledge first, process after

The 15-second budget covers your whole response. Store the event, return `2xx`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Subscribe to what you use

Updating `eventTypes` replaces the list, so keep the full list in your deployment code and send it every time.

## Conclusion

Browserbase signs every webhook with Standard Webhooks: an HMAC-SHA256 of `webhook-id`, `webhook-timestamp` and the raw body, keyed with your base64-decoded `whsec_` secret. Store the secret when you create the webhook, verify against the raw bytes, deduplicate on the envelope `id`, treat terminal states as final because events arrive unordered, and rotate secrets in Browserbase's recommended order.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each Browserbase delivery, stores it, and retries into your service on your schedule.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Browserbase webhooks reliably in minutes.