Webhooks let BabySea push event payloads to your server via HTTP POST — eliminating the need to poll GET /v1/content/{generation_id} and enabling proactive alerting for low credit balances.
Access control
owner and primary-owner roles can create, toggle, rotate, replay, drain, and delete endpoints. member roles have read-only visibility.
Event types
| Event | Sent when |
|---|
generation.started | Generation submitted and processing |
generation.completed | Generation finished successfully |
generation.failed | Generation finished with a failure |
generation.canceled | Generation was canceled |
credits.low_balance | Account balance crosses a configured alert threshold |
webhook.test | Manually triggered from the console via Send test event |
Setup
Requires: owner or primary-owner role and a publicly routable POST endpoint.
Open Webhook registry
Navigate to Webhooks in the console.
Add an endpoint
Click Add endpoint and enter your destination URL:https://api.example.com/webhooks/babysea
Select events
Choose which events to subscribe to. Generation events are selected by default:| Event | Default |
|---|
generation.started | Yes |
generation.completed | Yes |
generation.failed | Yes |
generation.canceled | Yes |
credits.low_balance | No |
Click Add
BabySea registers the endpoint and immediately displays the webhook secret exactly once:whsec_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6
Store this in your secrets manager before closing the dialog. It cannot be retrieved later. If lost, rotate the secret from the endpoint details page.
BABYSEA_WEBHOOK_SECRET=whsec_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6
Validate the connection
From the endpoint details page, click Send test event. BabySea dispatches a webhook.test payload. Verify your handler returns a 2xx response.
Endpoint details page
Selecting an endpoint opens its details view:
| Section | Description |
|---|
| Endpoint URL | Target URL with quick-copy |
| Status toggle | Enable or disable traffic |
| Events | Subscribed event types |
| Webhook secret | Masked prefix for identification |
| Created | Registration timestamp |
| Actions | Send test event, Rotate secret, Delete |
The lower half shows the Webhook events audit table with full delivery history.
Delivery
When a subscribed event occurs, BabySea:
- Queries active endpoints for the account
- Filters to those subscribed to the event
- Constructs the JSON payload
- Computes an HMAC-SHA256 signature
POSTs the payload to each matching endpoint in parallel
- Retries on failure (generation events only)
| Header | Description |
|---|
Content-Type | Always application/json |
X-BabySea-Signature | HMAC-SHA256 signature — see Verify signature |
X-BabySea-Event | Event type, e.g. generation.completed |
X-BabySea-Delivery-Id | Delivery UUID, also in the payload |
X-BabySea-Timestamp | Timestamp, also in the payload |
Retries and circuit breaker
BabySea retries generation events up to 5 times with exponential backoff (0s, 1s, 4s, 16s, 60s) on:
- Non-
2xx response (including 3xx redirects)
- Timeout
- Connection error (TCP refused, DNS failure, TLS error)
After 15 consecutive failures, the endpoint is automatically disabled (circuit-broken). BabySea queues eligible missed generation events in a dead-letter queue for up to 72 hours.
Re-enable paused endpoints and drain the queue promptly. Queued events are permanently lost after 72 hours.
Queued redelivery
After re-enabling a disabled endpoint:
Re-enable the webhook
Toggle the status switch to Enabled on the endpoint details page.
Drain the queue
An alert banner appears if queued events are pending. Click Deliver queued events.
Redelivery details
Each queued event is dispatched with a new delivery ID, current timestamp, and a fresh HMAC signature. The original payload is preserved.
Manual replay
Replay any specific delivery from the Webhook events audit table using the Resend action. Replay generates a new delivery ID, timestamp, and signature — the original webhook_data is preserved exactly.
Useful for recovering from processing failures, testing updated handler logic, or re-triggering dropped events.
Events and payloads
Every delivery shares the same envelope:
{
"webhook_event": "generation.completed",
"webhook_timestamp": "2025-01-15T12:00:00.000Z",
"webhook_delivery_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"webhook_data": { ... }
}
| Field | Type | Description |
|---|
webhook_event | string | One of the event types listed above |
webhook_timestamp | string | ISO 8601 dispatch timestamp |
webhook_delivery_id | string | Unique delivery UUID — use as idempotency key |
webhook_data | object | Event-specific payload |
Generation event fields
Optional fields are omitted (not null) when they don’t apply.
| Field | Type | Present on |
|---|
account_id | string | All |
model_identifier | string | All |
generation_id | string | All |
generation_status | string | All |
generation_provider_initialize | string | generation.started |
generation_provider_used | string | generation.completed |
generation_prediction_id | string | started, completed, canceled |
generation_output_file | string[] | generation.completed |
generation_error | string | generation.failed |
generation_error_code | string | generation.failed |
credits_refunded | boolean | generation.canceled |
generation.started
{
"webhook_event": "generation.started",
"webhook_timestamp": "2025-01-15T12:00:00.000Z",
"webhook_delivery_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"webhook_data": {
"account_id": "your-account-id",
"model_identifier": "your-model-identifier",
"generation_provider_initialize": "provider-name",
"generation_status": "processing",
"generation_prediction_id": "abc123",
"generation_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
generation.completed
{
"webhook_event": "generation.completed",
"webhook_timestamp": "2025-01-15T12:00:15.000Z",
"webhook_delivery_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"webhook_data": {
"account_id": "your-account-id",
"model_identifier": "your-model-identifier",
"generation_provider_used": "provider-name",
"generation_status": "succeeded",
"generation_prediction_id": "abc123",
"generation_id": "550e8400-e29b-41d4-a716-446655440000",
"generation_output_file": [
"https://your-storage-host.example/output-0.png"
]
}
}
generation.failed
{
"webhook_event": "generation.failed",
"webhook_timestamp": "2025-01-15T12:00:30.000Z",
"webhook_delivery_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"webhook_data": {
"account_id": "your-account-id",
"model_identifier": "your-model-identifier",
"generation_status": "failed",
"generation_id": "550e8400-e29b-41d4-a716-446655440000",
"generation_error": "Generation request failed",
"generation_error_code": "BSE4001"
}
}
generation.canceled
{
"webhook_event": "generation.canceled",
"webhook_timestamp": "2025-01-15T12:00:10.000Z",
"webhook_delivery_id": "d4e5f6a7-b8c9-0123-def0-234567890123",
"webhook_data": {
"account_id": "your-account-id",
"model_identifier": "your-model-identifier",
"generation_status": "canceled",
"generation_prediction_id": "abc123",
"generation_id": "550e8400-e29b-41d4-a716-446655440000",
"credits_refunded": true
}
}
credits_refunded is true if credits were returned, false otherwise. generation_prediction_id is only present if the generation was already submitted to a provider before cancellation.
credits.low_balance
{
"webhook_event": "credits.low_balance",
"webhook_timestamp": "2025-01-15T12:00:00.000Z",
"webhook_delivery_id": "f6a7b8c9-d0e1-2345-f012-456789012345",
"webhook_data": {
"account_id": "your-account-id",
"current_balance": 8.5,
"thresholds_crossed": [
{ "threshold": 10, "balance_at": 8.5 }
]
}
}
webhook.test
Dispatched by Send test event. Uses a null UUID as generation_id to test HMAC validation without a real generation.
{
"webhook_event": "webhook.test",
"webhook_timestamp": "2025-01-15T12:00:00.000Z",
"webhook_delivery_id": "e5f6a7b8-c9d0-1234-ef01-345678901234",
"webhook_data": {
"account_id": "your-account-id",
"model_identifier": "test",
"generation_status": "succeeded",
"generation_id": "00000000-0000-0000-0000-000000000000"
}
}
Routing events in code
import { type WebhookPayload } from 'babysea';
import { verifyWebhook } from 'babysea/webhooks';
async function handleWebhook(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-babysea-signature') ?? '';
const payload = await verifyWebhook(
rawBody,
signature,
process.env.BABYSEA_WEBHOOK_SECRET!,
);
switch (payload.webhook_event) {
case 'generation.completed':
for (const url of payload.webhook_data.generation_output_file ?? []) {
await saveOutput(payload.webhook_data.generation_id, url);
}
break;
case 'generation.failed':
await logFailure(
payload.webhook_data.generation_id,
payload.webhook_data.generation_error,
payload.webhook_data.generation_error_code,
);
break;
case 'generation.started':
await updateStatus(payload.webhook_data.generation_id, 'processing');
break;
case 'generation.canceled':
await updateStatus(payload.webhook_data.generation_id, 'canceled');
if (payload.webhook_data.credits_refunded) {
await notifyRefund(payload.webhook_data.generation_id);
}
break;
case 'credits.low_balance':
await notifyFinanceTeam(
payload.webhook_data.account_id,
payload.webhook_data.current_balance,
payload.webhook_data.thresholds_crossed,
);
break;
case 'webhook.test':
break;
}
return new Response('OK', { status: 200 });
}
Verify signature
Every delivery includes an X-BabySea-Signature header:
X-BabySea-Signature: t=1705315200,v1=5d41402abc4b2a76b9719d911017c592
| Part | Description |
|---|
t | Unix timestamp (seconds) when the signature was created |
v1 | HMAC-SHA256 hex digest of {t}.{rawBody} using your webhook secret |
Always verify the signature. Any actor can POST to your public endpoint — signature verification proves the payload came from BabySea and wasn’t tampered with in transit.
Using the SDK (recommended)
import { verifyWebhook } from 'babysea/webhooks';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-babysea-signature') ?? '';
try {
const payload = await verifyWebhook(
rawBody,
signature,
process.env.BABYSEA_WEBHOOK_SECRET!,
);
// Acknowledge immediately, process async
await queue.push(payload);
return new Response('OK', { status: 200 });
} catch {
return new Response('Invalid signature', { status: 400 });
}
}
verifyWebhook parses the header, checks timestamp freshness (default 5-minute tolerance), computes the HMAC, performs a timing-safe comparison, and returns the typed payload. It accepts an optional fourth argument for a custom tolerance in seconds.
Manual verification (TypeScript)
Always use the raw request body. Parsing and re-encoding JSON may alter whitespace or key order, breaking the signature.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): object {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2)),
);
const timestamp = parts['t'];
const signature = parts['v1'];
if (!timestamp || !signature) throw new Error('Missing signature components');
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance');
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const valid = timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex'),
);
if (!valid) throw new Error('Invalid signature');
return JSON.parse(rawBody);
}
Manual verification (Python)
import hmac, hashlib, time, json
def verify_babysea_webhook(raw_body: str, signature_header: str, secret: str, tolerance: int = 300) -> dict:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
raise ValueError("Missing signature components")
if abs(time.time() - int(timestamp)) > tolerance:
raise ValueError("Timestamp outside tolerance")
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid signature")
return json.loads(raw_body)
Framework handler examples
Next.js
Express
Python (Flask)
import { verifyWebhook } from 'babysea/webhooks';
export async function POST(req: Request) {
const rawBody = await req.text(); // NOT req.json()
const signature = req.headers.get('x-babysea-signature') ?? '';
try {
const payload = await verifyWebhook(rawBody, signature, process.env.BABYSEA_WEBHOOK_SECRET!);
await queue.push(payload);
return new Response('OK', { status: 200 });
} catch {
return new Response('Invalid signature', { status: 400 });
}
}
import express from 'express';
import { verifyWebhook } from 'babysea/webhooks';
// Use express.text() — express.json() re-serializes the body, breaking the signature
app.post('/webhook', express.text({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body; // string, not parsed JSON
const signature = req.headers['x-babysea-signature'] as string;
let payload;
try {
payload = await verifyWebhook(rawBody, signature, process.env.BABYSEA_WEBHOOK_SECRET!);
} catch {
return res.status(400).send('Invalid signature');
}
res.status(200).send('OK');
await queue.push(payload);
});
from flask import Flask, request, abort
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
raw_body = request.get_data(as_text=True) # NOT request.json
sig_header = request.headers.get("X-BabySea-Signature", "")
payload = verify_babysea_webhook(raw_body, sig_header, os.environ["BABYSEA_WEBHOOK_SECRET"])
process_event.delay(payload) # async
return "OK", 200
Troubleshooting
Invalid signature — almost always a body parsing issue. Your framework parsed the JSON body before your handler ran, so you’re verifying a re-serialized string instead of the original bytes.
| Framework | Fix |
|---|
| Express | Use express.text({ type: 'application/json' }) on the route |
| Next.js App Router | Use await req.text() instead of await req.json() |
| Fastify | Use rawBody with the fastify-raw-body plugin |
| Flask | Use request.get_data(as_text=True) instead of request.json |
Timestamp outside tolerance — your server clock is out of sync with UTC, or the webhook was delayed beyond the tolerance window. Ensure NTP sync. For more tolerance, pass a custom toleranceSeconds to the SDK helper.
Works locally, fails in production — a reverse proxy, CDN, or API gateway is modifying the request body (re-encoding JSON, stripping whitespace, decompressing gzip). Raw bytes must match exactly what BabySea sent.
Secret rotation
Rotate immediately if a secret is compromised, or on a routine schedule.
Open endpoint details
Navigate to the endpoint details page.
Click Rotate secret
BabySea warns that the old secret is invalidated immediately with no grace period.
Store the new secret
The new secret is shown once:whsec_x9Y8z7W6v5U4t3S2r1Q0p9O8n7M6l5K4
Store it immediately. It cannot be retrieved after closing the dialog.
Update your environment
BABYSEA_WEBHOOK_SECRET=whsec_x9Y8z7W6v5U4t3S2r1Q0p9O8n7M6l5K4
Update before the next delivery arrives — there is no overlap window.
Audit table
The Webhook events table on the endpoint details page shows:
| Column | Description |
|---|
| Event | webhook_event value |
| Generation ID | generation_id from the payload |
| Status | HTTP response code, color-coded |
| Error | Error message if delivery failed |
| Attempts | Number of delivery attempts |
| Delivered at | UTC delivery timestamp |
| Actions | Resend to replay the delivery |
HTTP status colors: green = 2xx, blue = 3xx, amber = 4xx, red = 5xx. The table supports batch selection, sorting, and export.
Delete an endpoint
Click Delete on the endpoint details page to permanently remove the endpoint, its entire delivery history, and any queued events.
Deletion is permanent and unrecoverable. If you only need to pause traffic, use the Disable toggle instead.