Skip to main content
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

EventSent when
generation.startedGeneration submitted and processing
generation.completedGeneration finished successfully
generation.failedGeneration finished with a failure
generation.canceledGeneration was canceled
credits.low_balanceAccount balance crosses a configured alert threshold
webhook.testManually triggered from the console via Send test event

Setup

Requires: owner or primary-owner role and a publicly routable POST endpoint.
1

Open Webhook registry

Navigate to Webhooks in the console.
2

Add an endpoint

Click Add endpoint and enter your destination URL:
https://api.example.com/webhooks/babysea
3

Select events

Choose which events to subscribe to. Generation events are selected by default:
EventDefault
generation.startedYes
generation.completedYes
generation.failedYes
generation.canceledYes
credits.low_balanceNo
4

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.
.env
BABYSEA_WEBHOOK_SECRET=whsec_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6
5

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:
SectionDescription
Endpoint URLTarget URL with quick-copy
Status toggleEnable or disable traffic
EventsSubscribed event types
Webhook secretMasked prefix for identification
CreatedRegistration timestamp
ActionsSend 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:
  1. Queries active endpoints for the account
  2. Filters to those subscribed to the event
  3. Constructs the JSON payload
  4. Computes an HMAC-SHA256 signature
  5. POSTs the payload to each matching endpoint in parallel
  6. Retries on failure (generation events only)

Request headers

HeaderDescription
Content-TypeAlways application/json
X-BabySea-SignatureHMAC-SHA256 signature — see Verify signature
X-BabySea-EventEvent type, e.g. generation.completed
X-BabySea-Delivery-IdDelivery UUID, also in the payload
X-BabySea-TimestampTimestamp, 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:
1

Re-enable the webhook

Toggle the status switch to Enabled on the endpoint details page.
2

Drain the queue

An alert banner appears if queued events are pending. Click Deliver queued events.
3

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": { ... }
}
FieldTypeDescription
webhook_eventstringOne of the event types listed above
webhook_timestampstringISO 8601 dispatch timestamp
webhook_delivery_idstringUnique delivery UUID — use as idempotency key
webhook_dataobjectEvent-specific payload

Generation event fields

Optional fields are omitted (not null) when they don’t apply.
FieldTypePresent on
account_idstringAll
model_identifierstringAll
generation_idstringAll
generation_statusstringAll
generation_provider_initializestringgeneration.started
generation_provider_usedstringgeneration.completed
generation_prediction_idstringstarted, completed, canceled
generation_output_filestring[]generation.completed
generation_errorstringgeneration.failed
generation_error_codestringgeneration.failed
credits_refundedbooleangeneration.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

TypeScript
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
PartDescription
tUnix timestamp (seconds) when the signature was created
v1HMAC-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.
TypeScript
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.
TypeScript
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)

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

TypeScript
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 });
  }
}

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.
FrameworkFix
ExpressUse express.text({ type: 'application/json' }) on the route
Next.js App RouterUse await req.text() instead of await req.json()
FastifyUse rawBody with the fastify-raw-body plugin
FlaskUse 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.
1

Open endpoint details

Navigate to the endpoint details page.
2

Click Rotate secret

BabySea warns that the old secret is invalidated immediately with no grace period.
3

Store the new secret

The new secret is shown once:
whsec_x9Y8z7W6v5U4t3S2r1Q0p9O8n7M6l5K4
Store it immediately. It cannot be retrieved after closing the dialog.
4

Update your environment

.env
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:
ColumnDescription
Eventwebhook_event value
Generation IDgeneration_id from the payload
StatusHTTP response code, color-coded
ErrorError message if delivery failed
AttemptsNumber of delivery attempts
Delivered atUTC delivery timestamp
ActionsResend 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.