Skip to main content
Webhooks let your backend react to Aurous Labs events without polling. Register an endpoint, subscribe to one or more event types, and we POST a signed payload as soon as the event fires.

Quick start

1

Register an endpoint

Send a POST /v1/webhook_endpoints with your HTTPS receiver URL. The response carries a one-time secret — store it; you cannot read it again.
curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints \
  -H "X-Api-Key: $AUROUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.acme.dev/aurous-webhook",
    "events": ["image.completed", "image.failed"]
  }'
Response (the only time secret is non-null):
{
  "id": "we_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
  "object": "webhook_endpoint",
  "url": "https://api.acme.dev/aurous-webhook",
  "events": ["image.completed", "image.failed"],
  "secret_preview": "whsec_8jK...wxyz",
  "secret": "whsec_8jKpQ4nXabc1234abcdef567890",
  "is_active": true,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "metadata": {},
  "created_at": "2026-05-04T01:00:00Z",
  "updated_at": "2026-05-04T01:00:00Z"
}
2

Verify the signature on every delivery

Every delivery carries an Aurous-Webhook-Signature header of the form t=<unix_sec>,v1=<hex>. Reconstruct the canonical signed payload (${t}.${raw_body}) and HMAC-SHA256 it with your stored secret. See the verifier examples.Aurous Labs uses Unix seconds (a 10-digit timestamp) for the t= parameter, matching the Stripe webhook convention.
3

Return 2xx to acknowledge

Acknowledge within 10 seconds. Any non-2xx, timeout, connection refused, or TLS error counts as a failed delivery and triggers a retry on the exponential-backoff schedule.

Event taxonomy

Subscribe to a subset via the events array, or pass ["*"] to subscribe to every type at create time (the wildcard is expanded server-side at create time — events added in a later API version do NOT auto-subscribe).
Event typeFires when
image.completedImage generation reached succeeded. Payload contains the rendered URLs.
image.failedImage generation failed (provider error, moderation, etc.).
image.cancelledCustomer called POST /v1/images/:id/cancel.
video.completedVideo generation reached succeeded.
video.failedVideo generation failed.
video.cancelledCustomer cancelled the video generation.
character.completedCharacter synthesis reached ready. Payload carries the synthesized character + its reference renders.
character.failedCharacter synthesis failed (provider error, moderation, or timeout). error_message carries a customer-safe code.
character.cancelledCustomer cancelled an in-flight synthesis via DELETE /v1/characters/:id.
usage.balance_lowTeam credits dropped to or below balance_low_threshold (1h debounced).
webhook.endpoint_disabledAnother endpoint on this team auto-disabled after sustained failures.
image.expired, video.expired, image.moderation_rejected, and video.moderation_rejected are reserved enum values that are not emitted today — see the Changelog for when each lights up.

Payload shape

Every delivery body is a single JSON object — the AurousEvent envelope:
{
  "id": "evt_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
  "object": "event",
  "type": "image.completed",
  "created_at": "2026-05-04T01:00:00Z",
  "synthetic": false,
  "data": {
    "id": "img_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
    "object": "image",
    "status": "succeeded",
    "...": "the same shape returned by GET /v1/images/:id"
  }
}
synthetic: true appears on the envelope (never inside data) when the delivery was triggered via POST /v1/webhook_endpoints/:id/test. Receivers can filter test fires from production traffic with a single field check.

Headers

Every delivery carries the following request headers:
HeaderExamplePurpose
Aurous-Webhook-Signaturet=1714867200,v1=8d3f...c4a1HMAC-SHA256 of ${t}.${raw_body} with your endpoint secret. Verify on every request.
Aurous-Event-Idevt_01HXMQ7Z3K8Y2NABCDEFGHJKMNSame id as the envelope. Use for de-dup on your side.
Aurous-Event-Typeimage.completedSame type as the envelope. Lets you route without parsing the body.
Aurous-Version2026-05-15API version that minted the event. Add a guard if your handler is version-locked.
User-AgentAurous-Labs-Webhooks/1.0Helps you allow-list our traffic at your edge.
Content-Typeapplication/jsonAlways JSON.

Signature format

Aurous-Webhook-Signature: t=<unix_sec>,v1=<hex> Where:
  • <unix_sec> is the moment we minted the signature (a 10-digit Unix timestamp in seconds — the Stripe convention).
  • <hex> is the lowercase hex of HMAC-SHA256(secret, "${t}.${raw_body}").
The t value goes INSIDE the HMAC payload, so an attacker cannot replay a captured body with a tweaked timestamp. We recommend rejecting any delivery whose t is older than 5 minutes — adjust the tolerance if your receiver uses a lossy queue.

Retries and dead-letter

Failed deliveries (non-2xx, timeout, connect refused, or TLS error) are retried on this schedule:
AttemptDelay before retry
1initial delivery
25 seconds
330 seconds
42 minutes
510 minutes
After attempt 5, the delivery is marked is_terminal: true and the row is flagged with is_dead_letter: true. We stop retrying. If consecutive_failures >= 20 AND there has been no successful delivery in the last 24 hours, we auto-disable the endpoint:
  • is_active flips to false.
  • We email the team owner.
  • Other active endpoints on the team receive a webhook.endpoint_disabled event so peer integrations can react.
Re-enable via PATCH /v1/webhook_endpoints/:id with { "is_active": true } once you’ve fixed the receiver. The counter resets to 0 on re-enable.

Receiver cookbook

The contract is symmetric: we sign with the active secret only; you verify against the active secret first, then the previous secret as a 24h fallback. This receiver-side fallback is what makes secret rotation zero-downtime.

Node.js (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();
const ACTIVE = process.env.AUROUS_WEBHOOK_SECRET;
const PREV = process.env.AUROUS_WEBHOOK_SECRET_PREV; // set ONLY for 24h after rotation

function verify(rawBody, sigHeader) {
  if (!sigHeader) return false;
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('=')),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  // `t` is Unix seconds — compare in seconds.
  const nowSec = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSec - Number(t)) > 5 * 60) return false; // 5min replay window
  const payload = `${t}.${rawBody}`;

  const tryWith = (secret) =>
    secret && crypto.timingSafeEqual(
      Buffer.from(crypto.createHmac('sha256', secret).update(payload).digest('hex')),
      Buffer.from(v1),
    );
  return tryWith(ACTIVE) || tryWith(PREV);
}

// Important: receive raw bytes — JSON.parse later, AFTER signature verify.
app.post(
  '/aurous-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8');
    if (!verify(raw, req.headers['aurous-webhook-signature'])) {
      return res.status(401).end();
    }
    const event = JSON.parse(raw);
    if (event.synthetic) return res.status(200).end(); // ignore test fires
    // ... handle event ...
    res.status(200).end();
  },
);

Python (Flask)

import hmac, hashlib, os, time, json
from flask import Flask, request, abort

ACTIVE = os.environ["AUROUS_WEBHOOK_SECRET"]
PREV = os.environ.get("AUROUS_WEBHOOK_SECRET_PREV")  # set ONLY for 24h after rotation

def verify(raw, sig_header):
    if not sig_header:
        return False
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    # `t` is Unix seconds — compare in seconds.
    if abs(int(time.time()) - int(t)) > 5 * 60:
        return False  # 5min replay window
    payload = f"{t}.{raw.decode('utf-8')}"

    def try_with(secret):
        if not secret:
            return False
        expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, v1)

    return try_with(ACTIVE) or try_with(PREV)

app = Flask(__name__)

@app.post("/aurous-webhook")
def aurous_webhook():
    raw = request.get_data()  # raw bytes — verify BEFORE json.loads
    if not verify(raw, request.headers.get("Aurous-Webhook-Signature")):
        abort(401)
    event = json.loads(raw)
    if event.get("synthetic"):
        return "", 200  # ignore test fires
    # ... handle event ...
    return "", 200

Manual curl verification

Useful for one-off debugging: capture a delivery body + signature, then verify locally.
# Save the body to body.json and the header value to a variable
SIG="t=1714867200,v1=8d3f...c4a1"
T=$(echo "$SIG" | sed -E 's/.*t=([^,]+).*/\1/')
V1=$(echo "$SIG" | sed -E 's/.*v1=([^,]+).*/\1/')

# Recompute over t.body
PAYLOAD="${T}.$(cat body.json | tr -d '\n')"
EXPECTED=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$AUROUS_WEBHOOK_SECRET" | awk '{print $2}')

# Constant-time compare (or eyeball)
if [ "$EXPECTED" = "$V1" ]; then echo "OK"; else echo "MISMATCH"; fi

Rotating the secret

Hit POST /v1/webhook_endpoints/:id/rotate_secret to mint a new plaintext. The response carries the new secret exactly once. The contract during rotation:
  • Sender (Aurous Labs): signs every new delivery with the active secret only.
  • Receiver (you): for the next 24 hours, store the previous secret as PREV and verify against ACTIVE first, then fall back to PREV (as shown in the cookbook).
Why? A delivery in flight at the moment of rotation may arrive seconds later, signed with the old secret. The 24h dual-validate window guarantees zero downtime for receivers under realistic clock skew + retry windows. After 24 hours, drop PREV. We never sign with it again.
curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/rotate_secret \
  -H "X-Api-Key: $AUROUS_KEY"
# → returns { "secret": "whsec_NEW_PLAINTEXT", ... } exactly once

Test firing

Hit POST /v1/webhook_endpoints/:id/test with an event_type to enqueue a real, signed synthetic delivery. The envelope carries synthetic: true so receivers can filter test fires from production traffic.
curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/test \
  -H "X-Api-Key: $AUROUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "image.completed" }'
# → 202 { "object": "webhook_test_fire", "endpoint_id": "we_...", "event_id": "evt_...", "enqueued": true }
Test fires use an isolated rate-limit bucket (webhooks_test, 30 / minute) so they never contend with normal webhook traffic.

Inspecting deliveries

Walk the per-attempt log via GET /v1/webhook_endpoints/:id/deliveries:
curl -G https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/deliveries \
  -H "X-Api-Key: $AUROUS_KEY" \
  --data-urlencode "limit=20"
Each row carries the attempt number, response status (or null for transport errors), error class (http_4xx / http_5xx / timeout / connect_refused / tls_error / connect_error), and the first 1KB of the response body if any. Cursor-paged via ?starting_after=<dlv_id>.