Skip to main content
Video generation is asynchronous by design. This recipe shows the minimum application loop you need for a production-safe integration.

1. Create the job

curl https://api.phaseo.ai/v1/videos \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "google/veo-3.1-lite",
    "prompt": "A cinematic sunrise over a mountain lake",
    "webhook": {
      "url": "https://example.com/api/video-webhook",
      "events": ["video.completed", "video.failed", "video.cancelled", "video.expired"],
      "secret": "whsec_your_signing_secret"
    }
  }'
const response = await fetch("https://api.phaseo.ai/v1/videos", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "google/veo-3.1-lite",
    prompt: "A cinematic sunrise over a mountain lake",
    webhook: {
      url: "https://example.com/api/video-webhook",
      events: ["video.completed", "video.failed", "video.cancelled", "video.expired"],
      secret: "whsec_your_signing_secret",
    },
  }),
});

const job = await response.json();
console.log(job.video_id);
import requests

response = requests.post(
    "https://api.phaseo.ai/v1/videos",
    headers={
        "Authorization": "Bearer YOUR_API_KEY",
        "Content-Type": "application/json",
    },
    json={
        "model": "google/veo-3.1-lite",
        "prompt": "A cinematic sunrise over a mountain lake",
        "webhook": {
            "url": "https://example.com/api/video-webhook",
            "events": ["video.completed", "video.failed", "video.cancelled", "video.expired"],
            "secret": "whsec_your_signing_secret",
        },
    },
)

job = response.json()
print(job.get("video_id"))
package main

import (
  "bytes"
  "fmt"
  "net/http"
)

func main() {
  body := []byte(`{
    "model": "google/veo-3.1-lite",
    "prompt": "A cinematic sunrise over a mountain lake",
    "webhook": {
      "url": "https://example.com/api/video-webhook",
      "events": ["video.completed", "video.failed", "video.cancelled", "video.expired"],
      "secret": "whsec_your_signing_secret"
    }
  }`)

  req, _ := http.NewRequest("POST", "https://api.phaseo.ai/v1/videos", bytes.NewBuffer(body))
  req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
  req.Header.Set("Content-Type", "application/json")

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  fmt.Println(resp.StatusCode)
}
using System.Net.Http.Headers;
using System.Text;

using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY");

var content = new StringContent("""
{
  "model": "google/veo-3.1-lite",
  "prompt": "A cinematic sunrise over a mountain lake",
  "webhook": {
    "url": "https://example.com/api/video-webhook",
    "events": ["video.completed", "video.failed", "video.cancelled", "video.expired"],
    "secret": "whsec_your_signing_secret"
  }
}
""", Encoding.UTF8, "application/json");

var response = await client.PostAsync("https://api.phaseo.ai/v1/videos", content);
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$ch = curl_init('https://api.phaseo.ai/v1/videos');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'model' => 'google/veo-3.1-lite',
        'prompt' => 'A cinematic sunrise over a mountain lake',
        'webhook' => [
            'url' => 'https://example.com/api/video-webhook',
            'events' => ['video.completed', 'video.failed', 'video.cancelled', 'video.expired'],
            'secret' => 'whsec_your_signing_secret',
        ],
    ]),
]);

$response = curl_exec($ch);
curl_close($ch);

echo $response;
require 'net/http'
require 'json'
require 'uri'

uri = URI('https://api.phaseo.ai/v1/videos')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = 'Bearer YOUR_API_KEY'
request['Content-Type'] = 'application/json'
request.body = {
  model: 'google/veo-3.1-lite',
  prompt: 'A cinematic sunrise over a mountain lake',
  webhook: {
    url: 'https://example.com/api/video-webhook',
    events: ['video.completed', 'video.failed', 'video.cancelled', 'video.expired'],
    secret: 'whsec_your_signing_secret'
  }
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

puts response.body
Store the returned video_id immediately.

2. Poll status until terminal

Your worker or application can poll:
curl https://api.phaseo.ai/v1/videos/VIDEO_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
const response = await fetch("https://api.phaseo.ai/v1/videos/VIDEO_ID", {
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
  },
});

const status = await response.json();
console.log(status.status);
import requests

response = requests.get(
    "https://api.phaseo.ai/v1/videos/VIDEO_ID",
    headers={
        "Authorization": "Bearer YOUR_API_KEY",
    },
)

status = response.json()
print(status.get("status"))
package main

import (
  "fmt"
  "net/http"
)

func main() {
  req, _ := http.NewRequest("GET", "https://api.phaseo.ai/v1/videos/VIDEO_ID", nil)
  req.Header.Set("Authorization", "Bearer YOUR_API_KEY")

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  fmt.Println(resp.StatusCode)
}
using System.Net.Http.Headers;

using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_API_KEY");

var response = await client.GetAsync("https://api.phaseo.ai/v1/videos/VIDEO_ID");
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$ch = curl_init('https://api.phaseo.ai/v1/videos/VIDEO_ID');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer YOUR_API_KEY',
    ],
]);

$response = curl_exec($ch);
curl_close($ch);

echo $response;
require 'net/http'
require 'uri'

uri = URI('https://api.phaseo.ai/v1/videos/VIDEO_ID')
request = Net::HTTP::Get.new(uri)
request['Authorization'] = 'Bearer YOUR_API_KEY'

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

puts response.body
Use polling for your own control loop even if you also enable webhooks. That gives you a direct way to recover if a webhook destination is temporarily unavailable. Persist and poll with the gateway-owned video id. native_video_id, when present, is provider-native correlation metadata; do not use it for Phaseo polling, websocket, cancellation, or content URLs.

3. Consume webhook deliveries

The current async webhook payloads are normalized around:
  • the job identifier
  • lifecycle status
  • delivery status summary
  • recent delivery attempts
  • whether signing is enabled
Design your webhook consumer to:
  1. verify the signature
  2. treat deliveries as retryable and idempotent
  3. fetch the latest job status if the webhook payload and local state disagree
When you configure webhook.secret, Phaseo signs each delivery with:
  • x-phaseo-timestamp: Unix timestamp in seconds
  • x-phaseo-signature: hex HMAC-SHA256 of ${timestamp}.${rawBody} using your webhook secret
  • x-phaseo-event-id: stable event id for this job/event
  • x-phaseo-event-type: event type such as video.completed
  • x-phaseo-delivery-key: idempotency key, including progress bucket when applicable
  • x-phaseo-attempt and x-phaseo-max-attempts: retry attempt metadata
Verify the signature against the exact raw request body before parsing JSON:
import { verifyAsyncWebhookSignature } from "@phaseo/sdk";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const ok = verifyAsyncWebhookSignature({
    secret: process.env.PHASEO_WEBHOOK_SECRET!,
    body: rawBody,
    headers: request.headers,
  });

  if (!ok) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // Persist event.id or the x-phaseo-delivery-key header before side effects.
  return Response.json({ received: true });
}
from phaseo import verify_async_webhook_signature


async def handle_video_webhook(request):
    raw_body = await request.body()
    ok = verify_async_webhook_signature(
        secret="whsec_your_signing_secret",
        body=raw_body,
        headers=request.headers,
    )
    if not ok:
        return {"status": 401, "body": "invalid signature"}

    event = await request.json()
    # Persist event["id"] or the x-phaseo-delivery-key header before side effects.
    return {"received": True}
Store x-phaseo-delivery-key or the payload id before side effects. Return any 2xx status only after your durable state has been updated; non-2xx responses are retried with the same event id and an incremented attempt number. Webhook subscriptions accept generic job.* events or matching video.* events. If you omit events, Phaseo subscribes the webhook to terminal job.completed, job.failed, job.cancelled, and job.expired notifications. If you provide events, at least one event must be valid for video jobs; all-invalid or cross-kind-only lists such as ["batch.completed"] are rejected instead of being broadened silently. Webhook callback URLs must use HTTPS. Literal private, loopback, link-local, and wildcard hosts are rejected; http://localhost, http://127.0.0.1, and http://[::1] are accepted only for local development callbacks.

4. Read the final output

When the job reaches a completed terminal state, fetch content from:
GET /v1/videos/{video_id}/content
If your application only needs a download URL, use the dedicated download-url surface where supported by the endpoint.

5. What to monitor

  • job lifecycle status
  • webhook delivery success and retry counts
  • last delivery HTTP status
  • failure timestamps and error messages
These signals should live together in your internal async-job dashboard so operations can distinguish generation failures from webhook-delivery failures.
Last modified on July 9, 2026