# Guide to Baseten Webhooks: Features and Best Practices

Baseten runs machine learning models in production, and its async inference API lets you queue a prediction that might take seconds or minutes, such as a long transcription, an image generation or a batch of embeddings, without holding a connection open. The result comes back as a webhook. You pass a `webhook_endpoint` when you queue the request, and Baseten posts the model's output there when it is done.

Endpoints must use HTTPS (except `localhost` for development). Baseten supports HTTP/2 and HTTP/1.1.

This guide covers how Baseten's per-request callbacks work, how to turn on and verify signing, and why a Baseten webhook receiver has to be more reliable than most: if it misses a delivery, the output is gone.

## What are Baseten webhooks?

Baseten webhooks are not subscriptions. There is no dashboard page where you register a URL or pick events. Each call to `/async_predict` carries its own `webhook_endpoint`, and Baseten sends exactly one kind of callback to it, `async_request_completed`, whether the model succeeded or failed. Different requests can point at different URLs.

Signing is optional and workspace-wide. Once you create a webhook secret in the workspace's **Secrets** tab, every callback carries `X-BASETEN-SIGNATURE` with a `v1=` prefix and a hex HMAC-SHA256 of the raw body. Without a secret, Baseten sends no signature header.

## Baseten webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per request: `webhook_endpoint` on `/async_predict` |
| Events | One callback type, `async_request_completed`, for success and failure |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-baseten-signature` |
| Header format | `v1=<hex>`. Several comma-separated values can appear |
| Signed content | The raw request body |
| Secret | A webhook secret in **Settings > Secrets**. Old and new are both valid for 24 hours after rotation |
| Correlation header | `X-BASETEN-REQUEST-ID` |
| Body | JSON |
| Event routing | Body field `type` |
| Timeout | 10 seconds per attempt |
| Retries | 2 attempts in total, the second about 2 seconds later, only on `500`, `502`, `503` or `504` |
| Source IPs | Not published |

## Events and payloads

| Event (`type`) | Triggered When | Common Use Cases |
| --- | --- | --- |
| `async_request_completed` | An async inference request finishes, successfully or not | Store the model output, mark a job done, alert on `errors` |

* Success: `data` holds the output (`{"output": ...}`) and `errors` is `[]`.
* Failure: `data` is `null` and `errors` holds objects such as `{"code": "MODEL_PREDICT_ERROR", "message": "..."}`.

### Event Payload Structure

`request_id` matches the response of your original `/async_predict` call. The `X-BASETEN-REQUEST-ID` header carries the same ID.

### Published payload example

This is the `async_request_completed` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/baseten/latest/async_request_completed.json). It comes from Baseten's async inference docs and shows a successful run.

```json
{
  "request_id": "9876543210abcdef1234567890fedcba",
  "model_id": "abc123",
  "deployment_id": "def456",
  "type": "async_request_completed",
  "time": "2024-04-30T01:01:08.883423Z",
  "data": {
    "output": "model response here"
  },
  "errors": []
}

```

Baseten shows the failure body only as a fragment, `{"errors": [...]}`, so check `errors` and `data` rather than assuming a different `type` for failures. `model_id` and `deployment_id` tell you which deployment produced the output, which matters when you are rolling out a new version.

## Setting up Baseten webhooks

### Create a webhook secret

Create a webhook secret in the Baseten Secrets tab and use it as the verification secret. Without one, deliveries are unsigned and fail verification.

1. Open [Baseten Settings > Secrets](https://app.baseten.co/settings/secrets).
2. Create a webhook secret.
3. Store it as `WEBHOOK_SECRET`. Use it exactly as shown, as UTF-8 bytes (do not hex- or base64-decode it).

### Pass the endpoint with each request

Set `webhook_endpoint` when you call `/async_predict`, and keep the `request_id` it returns so you can match the callback to the job:

```python
import requests, os

resp = requests.post(
    f"https://model-{model_id}.api.baseten.co/production/async_predict",
    headers={"Authorization": f"Api-Key {os.environ['BASETEN_API_KEY']}"},
    json={
        "model_input": {"prompt": "hello"},
        "webhook_endpoint": "https://example.com/webhooks/baseten",
    },
)
print(resp.json()["request_id"])

```

There is no event selection: every completed request (success or failure) triggers one `async_request_completed` callback.

During development, a `localhost` URL works over plain HTTP. To receive callbacks from Baseten's cloud on your machine, forward them with the [Hookdeck CLI](/docs/cli) and pass the forwarding URL as `webhook_endpoint`:

```bash
npx hookdeck-cli listen 3000 baseten --path /webhooks/baseten

```

## Securing Baseten webhooks

Read `X-BASETEN-SIGNATURE`, take each `v1=` value, compute an HMAC-SHA256 of the raw body with the secret's UTF-8 bytes, encode it as hex and compare in constant time. Accept the request if any value matches. Baseten's own Python sample cuts the header at the first comma, which suggests several signatures can arrive during secret rotation, although the docs don't describe the format. See the [verification documentation](https://docs.baseten.co/inference/async).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-baseten-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = signature.split(',').map((part) => part.trim())
    .filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler matches the callback to the job by `request_id`, records either the output or the errors, and only then answers. A storage failure returns `503`, which Baseten retries once. An invalid signature returns `401`, which Baseten does not retry.

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

function createBasetenApp(jobs) {
  const app = express();
  app.post('/webhooks/baseten', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let result;
    try {
      result = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!result || typeof result !== 'object' || Array.isArray(result)) return res.sendStatus(400);
    if (typeof result.request_id !== 'string') return res.sendStatus(400);
    try {
      if (result.errors?.length) {
        await jobs.fail(result.request_id, result.errors);
      } else {
        await jobs.complete(result.request_id, result.data?.output, result.deployment_id);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Only the body is signed, and the `time` field inside it isn't covered separately, so there is no timestamp to check. Record each `request_id` once and ignore repeats.

Hookdeck's Baseten source type reads the `v1=` value from `X-BASETEN-SIGNATURE` and verifies it as a hex HMAC-SHA256 of the raw body, keyed with your secret. If you deliberately leave signing off in Baseten, leave verification off on the source too. Hookdeck answers Baseten with `200` in every case, records a failed check as failed verification and does not forward that request. Hookdeck sets no time limit on the check.

> **Make Baseten webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) gives every `webhook_endpoint` a durable target, verifies the `v1=` signature, and keeps each inference result until your service has stored it.

## Baseten webhook limitations and pain points

### A missed callback loses the output

**The Problem:** A model run completes, you are billed for it, and the result never reaches you.

**Why It Happens:** Baseten delivers on a best-effort basis: a 10-second timeout, one retry about 2 seconds later, and only for `500`, `502`, `503` and `504`. If both attempts fail, the request status becomes `WEBHOOK_FAILED`. Baseten does not store async outputs, so there is nothing to fetch afterwards.

**Workarounds:**

* Write outputs to your own storage inside the model's `postprocess()` as well, so the webhook is a notification rather than the only copy.
* Check request status for `WEBHOOK_FAILED` and rerun those jobs.
* Keep the receiver fast and highly available.

**How Hookdeck Can Help:** Point `webhook_endpoint` at a Hookdeck source. Hookdeck accepts the callback on the first attempt and stores it, then retries your service on your schedule, so the output survives an outage on your side.

### Unsigned by default

**The Problem:** Anyone who learns your callback URL can post a fake result.

**Why It Happens:** The signature header is only included once a webhook secret exists in the workspace. New workspaces don't have one.

**Workarounds:**

* Create the secret before you send the first async request in production.
* Reject requests without `X-BASETEN-SIGNATURE` rather than skipping the check.
* Only accept `request_id` values you actually queued.

**How Hookdeck Can Help:** With verification on, Hookdeck's Baseten source rejects callbacks that don't carry a valid `v1=` signature, and shows them in the request log.

### Rotation overlaps for 24 hours

**The Problem:** After rotating the secret, callbacks fail verification, or the old secret keeps working longer than expected.

**Why It Happens:** Both the old and new secrets are valid for 24 hours after rotation, and the header may carry more than one `v1=` value. The exact multi-signature format isn't documented.

**Workarounds:**

* Accept a match on any `v1=` value, as the verifier above does.
* Update your stored secret promptly after rotating, and stop accepting the old one once the 24 hours are up.

**How Hookdeck Can Help:** Hookdeck reads every `v1=` value in the header, so a callback signed during the overlap verifies against whichever secret the source holds.

### One callback type for success and failure

**The Problem:** Routing on `type` sends failed runs down the success path.

**Why It Happens:** Both outcomes arrive as `async_request_completed`. The difference is in `data` (null on failure) and `errors`.

**Workarounds:**

* Branch on `errors.length`, then on `data`.
* Alert on error codes such as `MODEL_PREDICT_ERROR`.

**How Hookdeck Can Help:** A Hookdeck filter on the `errors` field can send failed runs to a separate destination, such as an alerting service.

## Best practices

### Verify the raw body

Parsing and re-serialising JSON changes whitespace and key order and breaks the HMAC. Use `express.raw({ type: '*/*' })` in Express, `await request.arrayBuffer()` in Next.js and `await request.body()` in FastAPI.

### Strip `v1=` and don't decode the secret

The header value is not the bare digest, and the key is the secret's UTF-8 bytes, not a decoded form.

### Choose response codes deliberately

Return `401` for a missing or wrong signature, because a retry won't fix it. Return `503` only when a retry could succeed, such as a database that is briefly unavailable, because Baseten retries only `500`, `502`, `503` and `504`.

### Correlate on `request_id`

Store the `request_id` from `/async_predict` when you queue the job, and match the callback against it. Ignore callbacks for IDs you don't know, and process each ID once. The [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) has more on storing keys.

### Keep the handler short

Large model outputs can make a handler slow. Store the output, answer `200`, and post-process asynchronously so you stay well inside the 10-second timeout. Our article on [asynchronous webhook processing](/webhooks/guides/why-implement-asynchronous-processing-webhooks) explains the pattern.

## Conclusion

Baseten's async webhooks carry the only copy of a model's output, and Baseten tries twice before giving up. Create a webhook secret, verify every `v1=` signature, correlate on `request_id`, and save outputs in `postprocess()` as a backstop.

[Hookdeck Event Gateway](https://hookdeck.com) gives your async requests a callback target that is always available and keeps each result until your service has it.

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