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:
dataholds the output ({"output": ...}) anderrorsis[]. - Failure:
dataisnullanderrorsholds 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. It comes from Baseten's async inference docs and shows a successful run.
{
"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.
- Open Baseten Settings > Secrets.
- Create a webhook secret.
- 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:
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 and pass the forwarding URL as webhook_endpoint:
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.
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.
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 gives every
webhook_endpointa durable target, verifies thev1=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_FAILEDand 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-SIGNATURErather than skipping the check. - Only accept
request_idvalues 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 ondata. - 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 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 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 gives your async requests a callback target that is always available and keeps each result until your service has it.
Get started with Hookdeck for free and handle Baseten webhooks reliably in minutes.