Webhooks
SandBase supports two outbound callback modes:
- Registered webhooks are scoped to your organization, subscribe to asynchronous Model events, and include an HMAC
X-Signature. - Per-task model callbacks use
webhook_urlon one asynchronous image, video, or audio request. They do not require registration and do not includeX-Signature.
Both modes use at-least-once delivery. Make handlers idempotent with the event ID.
Register a webhook
The Events API is the public API-key surface:
POST https://api.sandbase.ai/events/webhooksYou can also create and manage webhooks from the Console Webhooks page.
curl -X POST https://api.sandbase.ai/events/webhooks \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Model generation monitor",
"url": "https://example.com/hooks/sandbase",
"resourceType": "model",
"events": ["model.generation.completed", "model.generation.failed"]
}'| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | A human-readable name. |
url | string | Yes | A valid HTTP(S) endpoint. Use HTTPS in production. |
resourceType | model | Yes | Selects the asynchronous Model events below. model is the only supported value. |
events | string[] | No | Empty or omitted means all current and future events in the selected resource type. |
enabled | boolean | No | Defaults to true. |
signatureSecret | string | No | Omit this field to let SandBase generate a high-entropy secret. Existing clients may continue to provide their own non-empty value. |
The session, sandbox, and template resource types are retired: creating a webhook with any of them returns HTTP 400. Existing registrations of these types are listed for cleanup: a PATCH on them returns HTTP 400 unless the same request changes resourceType to model (and the resulting subscription is valid), so delete them or convert them to model. model subscriptions are available only for the asynchronous image, video, and audio terminal events listed below. The API rejects events from other resource types and unknown event names.
Successful response
The Events API returns the registered webhook directly. The final signatureSecret is included only in this create response. Store it securely now: list and edit responses never return it, and it cannot be recovered later.
{
"id": "wh_01...",
"name": "Model generation monitor",
"url": "https://example.com/hooks/sandbase",
"resourceType": "model",
"events": ["model.generation.completed", "model.generation.failed"],
"enabled": true,
"createdAt": "2026-07-15T12:00:00Z",
"signatureSecret": "whsec_<one-time-value>"
}The Console Webhooks page shows the generated secret once after registration and requires you to save it before closing the dialog.
List, edit, and delete
GET https://api.sandbase.ai/events/webhooks
PATCH https://api.sandbase.ai/events/webhooks/{webhookID}
DELETE https://api.sandbase.ai/events/webhooks/{webhookID}Use the same API-key authentication. A PATCH may include one or more of name, url, resourceType, events, and enabled; omitted fields remain unchanged. The complete resulting resourceType and events subscription must be valid. An empty events array still means all events in that resource type.
curl -X PATCH https://api.sandbase.ai/events/webhooks/wh_01... \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": ["model.generation.completed", "model.generation.timeout"],
"enabled": true
}'signatureSecret cannot be viewed or changed through PATCH; a request containing it is rejected. If the secret is lost, delete and recreate the webhook. You can also view, edit, enable or disable, and delete registrations from the Console Webhooks page.
GET returns a JSON array of webhooks (without signatureSecret), PATCH returns the updated webhook, and DELETE returns 204 No Content.
Authentication and errors
The Events API accepts the API key as Authorization: Bearer $SANDBASE_API_KEY or X-API-Key: $SANDBASE_API_KEY. When both are sent, the Bearer header is used.
Errors use their own body, with the HTTP status repeated in code:
{"code": 400, "message": "resourceType must be \"model\""}Branch on the HTTP status. 401 means the key is missing, invalid, or revoked; 400 means the request body or subscription is invalid; 404 (webhook not found) means the webhook does not exist in your organization.
Per-task model callback
For one asynchronous image, video, or audio task, add webhook_url to POST /v1/run instead of registering a new webhook:
curl -X POST https://api.sandbase.ai/v1/run \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "example/async-image-model",
"mode": "async",
"prompt": "A lighthouse at night",
"webhook_url": "https://hooks.example.com/sandbase/task?token=opaque-token"
}'The field is parsed and validated only when the selected model is image, video, or audio and the request resolves to asynchronous execution. In that case it must be a non-empty string containing a valid, safe public HTTPS URL; otherwise the request returns HTTP 400. For synchronous, streaming, LLM, or other unsupported requests, SandBase ignores webhook_url regardless of its JSON value: it schedules no callback, stores no callback URL, and does not forward the field to the model provider. Omitting the field preserves the existing polling flow.
The target must be a public HTTPS URL. SandBase sends the same Model terminal envelope documented below, with Content-Type: application/json and X-Event-ID, for completed, failed, timeout, or cancelled. It may retry a failed delivery, so deduplicate by id or X-Event-ID and return 2xx quickly.
A per-task callback has no shared SandBase secret and therefore does not include X-Signature. If your endpoint requires HMAC verification, omit webhook_url and register an organization Model Webhook instead. You may place an opaque capability token generated by your own system in the HTTPS URL query and validate it at your endpoint. Treat that URL as sensitive.
For each asynchronous model task, target selection is exclusive. If the request includes a valid webhook_url, SandBase sends that Prediction's terminal event only to the per-task URL and skips every registered organization Model Webhook for that event. If the request omits webhook_url, registered organization Model Webhooks continue to receive matching events normally. A per-task delivery failure retries the same task URL and never falls back to a registered webhook. This priority does not create, replace, disable, or modify any registration, and does not affect other Model events.
Event types
Model generation
Model webhooks are available only for asynchronous image, video, and audio generation. They are not emitted for synchronous image/video/audio requests or any synchronous LLM request: those results are returned by the original HTTP response.
| Event | Description |
|---|---|
model.generation.completed | An asynchronous image, video, or audio generation completed. |
model.generation.failed | An asynchronous image, video, or audio generation failed. |
model.generation.timeout | An asynchronous image, video, or audio generation timed out. |
model.generation.cancelled | An asynchronous image, video, or audio generation was cancelled. |
Delivery payload
SandBase sends Content-Type: application/json and the following JSON envelope. data is event-specific and can gain fields over time.
Model completion example
{
"id": "model_pred_01_completed",
"resourceType": "model",
"resourceID": "pred_01...",
"type": "model.generation.completed",
"data": {
"predictionID": "pred_01...",
"model": "example-image-model",
"generationType": "image",
"status": "completed",
"completedAt": "2026-07-14T09:30:38Z",
"outputs": ["https://.../generated-image.png"]
},
"createdAt": "2026-07-14T09:30:38Z"
}Failure and timeout events omit outputs and contain only the sanitized error category.
| Field | Description |
|---|---|
id | Unique event ID. Use this, or X-Event-ID, as your idempotency key. |
resourceType | model. |
resourceID | The affected Prediction ID. |
type | Event type from the table above. |
data | Event-specific data. |
createdAt | Event creation time in RFC 3339 UTC. |
Verify a registered webhook signature
Every registered webhook delivery includes these headers:
| Header | Value |
|---|---|
X-Event-ID | The payload id. |
X-Signature | Lowercase hexadecimal HMAC-SHA256 of the raw request body, keyed by signatureSecret. |
Verify the signature against the raw request bytes before parsing JSON. Per-task webhook_url callbacks do not include this header and cannot use this verification flow. The following handlers are for registered webhooks: they verify first, parse second, use id as the idempotency key, and hand work to an asynchronous function. Replace the placeholder queue call with your own durable queue or worker.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const webhookSecret = process.env.SANDBASE_WEBHOOK_SECRET
if (!webhookSecret) throw new Error('SANDBASE_WEBHOOK_SECRET is required')
const acceptedEventIDs = new Set() // Use a DB unique key in production.
function enqueueSandBaseEvent(event) {
setImmediate(() => {
console.log(`processing ${event.type} (${event.id})`)
// Perform slow business work here or publish to your durable queue.
})
}
// This route must be registered before express.json() for the webhook path.
app.post('/hooks/sandbase', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body
const signature = req.get('X-Signature') || ''
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex')
const received = Buffer.from(signature, 'utf8')
const expectedBuffer = Buffer.from(expected, 'utf8')
if (received.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, received)) {
return res.status(401).json({ error: 'invalid signature' })
}
const event = JSON.parse(rawBody.toString('utf8'))
if (acceptedEventIDs.has(event.id)) return res.sendStatus(204)
acceptedEventIDs.add(event.id)
enqueueSandBaseEvent(event)
return res.sendStatus(204)
})
app.listen(3000)import hashlib
import hmac
import json
import os
import threading
from flask import Flask, abort, request
app = Flask(__name__)
webhook_secret = os.environ["SANDBASE_WEBHOOK_SECRET"].encode()
accepted_event_ids = set() # Use a DB unique key in production.
accepted_event_ids_lock = threading.Lock()
def process_sandbase_event(event):
print(f"processing {event['type']} ({event['id']})")
# Perform slow business work here or publish to your durable queue.
@app.post("/hooks/sandbase")
def sandbase_webhook():
raw_body = request.get_data(cache=True)
received = request.headers.get("X-Signature", "")
expected = hmac.new(webhook_secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
abort(401, "invalid signature")
event = json.loads(raw_body)
with accepted_event_ids_lock:
if event["id"] in accepted_event_ids:
return "", 204
accepted_event_ids.add(event["id"])
threading.Thread(target=process_sandbase_event, args=(event,), daemon=True).start()
return "", 204The HMAC input is the exact raw body. Do not call a JSON parser or re-serialize the payload before computing it.
X-Signature = hex(HMAC-SHA256(signatureSecret, rawRequestBody))Delivery behavior
- A response in the
2xxrange within 10 seconds is successful. - Network errors, timeouts, and non-2xx responses are failures. The existing event dispatcher may retry failed delivery; receivers must tolerate duplicate delivery.
- Return a 2xx response after durable acceptance, then perform slow work asynchronously. The examples use in-memory deduplication only to stay runnable; production receivers must replace it with a database unique key or durable queue. Do not assume exactly-once delivery or event ordering.