Skip to content
INFRO

Documentation

Webhooks

Receive job events over HTTP: event types, delivery payloads, signature verification in Python, TypeScript, and Dart, retries, and idempotency.


A webhook is how you learn that an async job finished without polling for it. When a job reaches a terminal state, INFRO POSTs a signed JSON event to your endpoint, so a long video render costs you one inbound request instead of a hundred outbound ones.

Configure one endpoint for the whole account in the console, or attach a webhook object to a single request. Per-request configuration replaces the account endpoint for that job rather than adding to it.

Configuring an endpoint

Per-request webhook, at the top level of the request body
{
  "model": "google/veo-3.1",
  "prompt": "A paper boat drifting down a rain-slicked gutter, macro lens",
  "duration_seconds": 8,
  "webhook": {
    "url": "https://example.com/hooks/infro",
    "events": ["job.succeeded", "job.failed"]
  }
}
webhook.urlstringrequired
HTTPS endpoint to POST to. Must be publicly reachable and must answer within 10 seconds. Plain HTTP is rejected at submit time with 400 invalid_request_error.
webhook.eventsarray
Which events to deliver. Defaults to every event the job can emit. Subscribing to only job.succeeded is a common mistake — you then never hear about failures.

Each endpoint has its own signing secret, shown once when it is created and prefixed whsec_. Store it beside your API key, never in client code. Rotating from the console keeps the old secret valid during an overlap window, so you can ship the new value without dropping deliveries.

Event types

TypeFires when
job.succeededA job reached succeeded. data.output and data.usage are populated.
job.failedEvery provider and every fallback was exhausted. data.error explains why; the job is not billed.
job.canceledThe job was canceled through POST /v1/jobs/{id}/cancel.

Delivery payload

json
{
  "id": "evt_2b9f0c5a7d1e4f30",
  "type": "job.succeeded",
  "created_at": 1755950139,
  "data": {
    "id": "job_7d41ba90",
    "object": "job",
    "type": "video.generation",
    "status": "succeeded",
    "model": "google/veo-3.1",
    "provider": "vertex",
    "created_at": 1755950000,
    "completed_at": 1755950139,
    "expires_at": 1756036539,
    "output": {
      "url": "https://cdn.infro.io/vid/7d41ba90.mp4",
      "duration_seconds": 8,
      "mime_type": "video/mp4"
    },
    "usage": {
      "seconds": 8,
      "cost": 3.2
    },
    "error": null
  }
}

data is the complete job object, byte for byte what GET /v1/jobs/{id} would return at that moment. Deliveries carry Content-Type: application/json, a INFRO-Signature header, and INFRO-Event-Id — the same value as id, convenient for log correlation.

Signature verification

Every delivery is signed: INFRO-Signature: t=1755950139,v1=5257a869.... t is the unix timestamp at send time, and v1 is the hex HMAC-SHA256 of the string {t}.{raw body} keyed by the endpoint's signing secret.

Three things make or break a verifier. Hash the raw bytes of the body, before any JSON parse and re-serialize — one reordered key changes the digest. Compare in constant time, so a timing side channel cannot leak the expected value. And reject anything whose t is more than five minutes old, which limits how long a captured delivery stays replayable.

import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)

SIGNING_SECRET = os.environ["INFRO_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300

_seen: set[str] = set()  # use Redis or a unique index in production


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False

    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(),
        timestamp.encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)


@app.post("/hooks/infro")
def infro_webhook():
    header = request.headers.get("INFRO-Signature", "")
    if not verify(request.get_data(), header, SIGNING_SECRET):
        return "", 400

    event = request.get_json()

    if event["id"] in _seen:  # delivery is at-least-once
        return "", 200
    _seen.add(event["id"])

    if event["type"] == "job.succeeded":
        print("download", event["data"]["output"]["url"])  # enqueue, do not block

    return "", 200


if __name__ == "__main__":
    app.run(port=3000)

Retries and delivery guarantees

  • Any non-2xx status, a connection error, or a response slower than 10 seconds counts as a failed delivery.
  • Failures retry with exponential backoff for up to 24 hours, then the event is dropped. The job is unaffected — you can always recover state from GET /v1/jobs/{id}.
  • Delivery is at-least-once. A delivery your handler processed but failed to acknowledge is retried, so the same event can arrive more than once.
  • Order is not guaranteed. Two jobs finishing milliseconds apart can arrive in either order, so never infer sequence from arrival time — use created_at.

Deduplicate on the event id, not on data.id: one job emits several events over its life, and keying on the job would drop legitimate ones. A unique index on id, or a short-lived key in Redis, is enough.

Responding quickly

Return 2xx as soon as the signature checks out and you have durably recorded the event. Do the download, transcode, or database fan-out on a queue. A handler that waits on a 90 MB video fetch will blow the 10-second budget, earn a retry, and then race itself as the retry arrives mid-download.

Failed deliveries also mean a caller sees nothing, so alert on your own 4xx and 5xx rate for the webhook route. A signature that stops verifying after a deploy is nearly always a body-parsing middleware that consumed the raw bytes first.

Testing locally

  • Expose your local server with a tunnel (cloudflared tunnel --url http://localhost:3000, ngrok, or similar) and pass the tunnel URL as webhook.url on a single request.
  • Generate real events cheaply: submit the shortest, lowest-resolution clip a model allows, or submit and immediately cancel to get a job.canceled delivery.
  • The console keeps a delivery log with the request body, response status, and a resend control — replaying a delivery is the fastest loop for iterating on handler code.
  • Test the rejection path too. Flip one character of the signature and confirm your endpoint answers 400 rather than processing the event.
  • Point the tunnel at a staging endpoint with its own signing secret so a local experiment can never acknowledge a production event.

Key handling and header format are covered in Authentication, and the status codes a failed job reports in Errors.