Skip to main content
← Developer Guide

Webhooks and job completion

The current Developer API beta is polling-first. It does not accept webhook_url or callback_url, and Puppetry does not send video job events to customer endpoints yet.

Current integration contract

Store the returned status_url and job id, then poll from your server with the same API key. Honor Retry-After or X-Puppetry-Next-Poll-At before the next lookup.

Production pattern

Treat polling as event delivery

  1. 1. Persist acceptance. Save the public job id and status_url returned by the 202 create response.
  2. 2. Poll server-side. Schedule the next lookup from a worker or queue. Never expose your bearer key in browser code.
  3. 3. Follow pacing. Prefer the response retry headers or JSON aliases over a fixed high-frequency interval.
  4. 4. Handle terminal state once. Make your completion handler idempotent by public job id so a worker restart cannot duplicate downstream work.
queued
Keep the original job id and wait for the response pacing hint before polling again.
processing
Report progress if useful, then poll the same status URL after Retry-After or next_poll_at.
completed
Persist the video_url and run your terminal completion handler once for this job id.
failed
Inspect error and retryable. Stop when retryable is false; do not silently create a duplicate job.

Use the SDK polling helper

waitForCompletion() keeps the same job identity, honors server pacing hints, and returns only after a terminal status.

import { Puppetry } from '@puppetry.com/sdk';

const client = new Puppetry({
  apiKey: process.env.PUPPETRY_API_KEY!,
});

const job = await client.videos.createFromText({
  image_url: 'https://example.com/portrait.jpg',
  text: 'Your video is ready.',
  voice: 'puppetry-af_heart',
  idempotencyKey: 'order-1842-video',
});

const terminalJob = await job.waitForCompletion({
  timeoutMs: 5 * 60_000,
  onPoll: (latest) => {
    console.log(latest.id, latest.status, latest.progress ?? 0);
  },
});

if (terminalJob.status === 'failed') {
  throw new Error(terminalJob.error ?? 'Video generation failed');
}

await handleTerminalJob(terminalJob.id, terminalJob.video_url!);

Poll with raw HTTP

If you do not use the SDK, follow the URL and pacing data in each response. A retryable lookup error belongs to the existing job lifecycle; it is not a signal to submit a replacement.

const API_ORIGIN = 'https://www.puppetry.com';
const apiKey = process.env.PUPPETRY_API_KEY!;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function pacingDelayMs(response: Response, body: Record<string, unknown>) {
  const retryAfter = Number(
    response.headers.get('Retry-After') ??
      body.retry_after_seconds ??
      body.retryAfter,
  );
  if (Number.isFinite(retryAfter)) return Math.max(retryAfter, 1) * 1000;

  const nextPollAt = String(
    response.headers.get('X-Puppetry-Next-Poll-At') ??
      body.next_poll_at ??
      body.nextPollAt ??
      '',
  );
  const absoluteDelay = Date.parse(nextPollAt) - Date.now();
  return Number.isFinite(absoluteDelay) ? Math.max(absoluteDelay, 1000) : 2000;
}

async function pollVideo(initialStatusUrl: string) {
  let pollUrl = new URL(initialStatusUrl, API_ORIGIN);

  for (;;) {
    const response = await fetch(pollUrl, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const body = await response.json();

    if (!response.ok && body.retryable !== true) {
      throw new Error(body.message ?? 'Status lookup failed');
    }

    if (response.ok && body.status === 'completed') return body;
    if (response.ok && body.status === 'failed') {
      throw new Error(body.error ?? 'Video generation failed');
    }

    const nextLocation =
      response.headers.get('Content-Location') ??
      body.status_url ??
      body.statusUrl;
    if (nextLocation) pollUrl = new URL(nextLocation, API_ORIGIN);

    await sleep(pacingDelayMs(response, body));
  }
}

Keep your application webhook-ready

Put completed and failed jobs through one internal terminal-job handler, regardless of how your application learned about the state. That boundary makes a future delivery mechanism a small adapter change instead of a rewrite of billing, notifications, or asset persistence.

Persist

Job id, latest status, video URL or error, and whether your terminal handler has run.

Deduplicate

Use the public job id as the idempotency key for downstream completion work.