Guides

Webhooks

Webhooks allow you to receive real-time HTTP notifications when events occur, like when a video finishes generating. This eliminates the need for polling.

Coming Soon

The webhooks API is under active development. Currently, use Firestore real-time updates or polling to track project status. This guide shows the planned webhook design.

How Webhooks Work

When you configure a webhook, Twin.Actor will send an HTTP POST request to your specified URL whenever certain events occur:

Twin.Actor
Event occurs
POST
Your Server
Receives event

Event Types

EventDescription
project.createdA new project was created
project.processingVideo generation started
project.completedVideo is ready for download
project.failedGeneration failed
voice_clone.completedVoice clone is ready
voice_clone.failedVoice cloning failed
credits.lowCredit balance below threshold
credits.depletedCredit balance is zero

Webhook Payload

All webhook payloads follow this structure:

json
{
  "id": "evt_abc123",
  "type": "project.completed",
  "created_at": "2024-03-20T10:15:00Z",
  "data": {
    "project_id": 42,
    "project_type": "scene_project",
    "video_url": "https://storage.twin.actor/videos/project_42_final.mp4",
    "duration_seconds": 50,
    "credits_used": 500
  }
}

project.completed

json
{
  "id": "evt_abc123",
  "type": "project.completed",
  "created_at": "2024-03-20T10:15:00Z",
  "data": {
    "project_id": 42,
    "project_type": "scene_project",
    "video_url": "https://storage.twin.actor/videos/project_42_final.mp4",
    "video_url_upscaled": null,
    "thumbnail_url": "https://storage.twin.actor/thumbs/project_42.jpg",
    "duration_seconds": 50,
    "credits_used": 500,
    "person_id": 1,
    "person_name": "Alex Chen"
  }
}

project.failed

json
{
  "id": "evt_abc124",
  "type": "project.failed",
  "created_at": "2024-03-20T10:15:00Z",
  "data": {
    "project_id": 42,
    "project_type": "scene_project",
    "error_code": "VIDEO_GENERATION_FAILED",
    "error_message": "Scene 3 video generation failed after 3 retries",
    "failed_scene": 3,
    "credits_refunded": 200
  }
}

voice_clone.completed

json
{
  "id": "evt_abc125",
  "type": "voice_clone.completed",
  "created_at": "2024-03-20T10:05:00Z",
  "data": {
    "person_id": 1,
    "person_name": "Alex Chen",
    "voice_id": "voice_abc123",
    "provider": "elevenlabs",
    "credits_used": 500
  }
}

Signature Verification

Every webhook request includes a signature header for verification:

http
X-Webhook-Signature</span>: t=1679529600,v1=abc123def456...
X-Webhook-Event</span>: project.completed
X-Webhook-ID</span>: evt_abc123

The signature is a HMAC-SHA256 hash of the timestamp and payload. Always verify signatures to ensure requests are from Twin.Actor:

python
"text-violet-400">import hmac
"text-violet-400">import hashlib
"text-violet-400">import time
"text-violet-400">from flask "text-violet-400">import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"

"text-violet-400">def verify_signature(payload: bytes, signature: str) -> bool:
    """Verify webhook signature ">with timestamp validation."""
    "text-violet-400">try:
        parts = dict(p.split("=") "text-violet-400">for p "text-violet-400">in signature.split(","))
        timestamp = int(parts["t"])
        provided_sig = parts["v1"]

        # Reject old timestamps (5 minute window)
        "text-violet-400">if abs(time.time() - timestamp) > 300:
            "text-violet-400">return "text-violet-400">False

        # Compute expected signature
        signed_payload = f"{timestamp}.{payload.decode()}"
        expected = hmac.new(
            WEBHOOK_SECRET.encode(),
            signed_payload.encode(),
            hashlib.sha256
        ).hexdigest()

        "text-violet-400">return hmac.compare_digest(provided_sig, expected)
    "text-violet-400">except:
        "text-violet-400">return "text-violet-400">False

@app.route("/webhooks/twin-actor", methods=["POST"])
"text-violet-400">def handle_webhook():
    signature = request.headers.get("X-Webhook-Signature", "")

    "text-violet-400">if "text-violet-400">not verify_signature(request.data, signature):
        "text-violet-400">return jsonify({"error": "Invalid signature"}), 401

    event = request.json
    event_type = event["type"]

    "text-violet-400">if event_type == "project.completed":
        video_url = event["data"]["video_url"]
        # Download video, notify user, etc.
        print(f"Video ready: {video_url}")

    "text-violet-400">elif event_type == "project.failed":
        error = event["data"]["error_message"]
        # Handle failure, alert team, etc.
        print(f"Project failed: {error}")

    "text-violet-400">return jsonify({"received": "text-violet-400">True}), 200

Best Practices

  • Always verify signatures - Never process unsigned webhook requests
  • Respond quickly - Return a 2xx status within 30 seconds. Process events asynchronously if needed.
  • Handle duplicates - Use the event ID to deduplicate. Webhooks may be retried if your server doesn't respond.
  • Use HTTPS - Webhook endpoints must use HTTPS in production
  • Log events - Store raw payloads for debugging and auditing

Retry Policy

If your endpoint returns a non-2xx status or times out, we'll retry with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
415 minutes
51 hour
62 hours (final)

After 6 failed attempts, the webhook endpoint will be disabled. Check the developer portal to view delivery logs and re-enable.

Current Alternative: Firestore

While webhooks are being developed, you can use Firestore real-time updates to track project status:

javascript
"text-violet-400">import { doc, onSnapshot } "text-violet-400">from "firebase/firestore";
"text-violet-400">import { db } "text-violet-400">from "./firebase";

// Listen "text-violet-400">for project status updates
"text-violet-400">const unsubscribe = onSnapshot(
  doc(db, "projects", projectId),
  (doc) => {
    "text-violet-400">const data = doc.data();
    console.log("Status:", data.status);
    console.log("Progress:", data.progress);

    "text-violet-400">if (data.status === "completed") {
      console.log("Video URL:", data.final_video_url);
      unsubscribe(); // Stop listening
    }
  }
);