DocsAPI reference
Webhooks
Receive job events over HTTP: event types, delivery payloads, signature verification in Python, TypeScript, and Dart, retries, and idempotency.
INFRO is in private early access. Running these examples requires an early-access account, an active trial or subscription entitlement, and your own provider connection. Self-serve purchasing is not generally open. See Early access.
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 JSON event, signed when it goes to a console-configured endpoint, so a long video render costs you one inbound request instead of a hundred outbound ones.
Configure endpoints for your organization in the console — every active endpoint receives each event it subscribes to — or attach a webhook object to a single request. Per-request configuration replaces all of your organization's endpoints for that job rather than adding to it.
Configuring an endpoint
{
"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; an address that is not publicly routable is refused at send time. webhook.eventsarray- Which of
job.succeeded,job.failed, andjob.canceledto deliver. Defaults to all three. Subscribing to onlyjob.succeededis a common mistake — you then never hear about failures.
Each console endpoint has its own signing secret, prefixed whsec_. Store it beside your API key, never in client code. Rotating from the console keeps the old secret signing for 24 hours alongside the new one — the header then carries two v1 values — so you can ship the new value without dropping deliveries. A per-request webhook.url has no secret of its own: its deliveries are signed with the secrets of every active console endpoint in the organization (one v1 per secret), so any of those secrets verifies them. With no active console endpoint, the delivery carries no INFRO-Signature header at all.
Event types
| Type | Fires when |
|---|---|
job.succeeded | A job reached succeeded. data.output and data.usage are populated. |
job.failed | The render could not be submitted, the provider reported it failed, or it timed out. data.error explains why; INFRO records no cost. |
job.canceled | The job was canceled through POST /v1/jobs/{id}/cancel. |
Spend and provider alert events (spend.anomaly, spend.forecast_over_budget, cost.drift, budget.threshold_crossed, connection.failing, unpriced.traffic, traffic.model_changed, traffic.regression) use the same envelope and the same signature, but are sent only to endpoints you have added as an alert channel with a notification rule — never because an endpoint's event list is empty. Their data is the alert object, and they are retried on their own schedule. See Alerts.
Delivery payload
{
"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",
"route": "primary",
"provider": "openai-compatible",
"connection": "conn_8b21c4e0",
"progress": 1,
"created_at": 1755950000,
"started_at": 1755950006,
"completed_at": 1755950139,
"expires_at": 1756036539,
"output": {
"url": "https://api.infro.io/media/vid/2026-09-24/org_.../job_7d41ba90.mp4?exp=1756036539&sig=...",
"duration_seconds": 8,
"resolution": "1080p",
"aspect_ratio": "16:9",
"mime_type": "video/mp4"
},
"usage": {
"seconds": 8,
"cost": 3.2,
"price_source": "list"
},
"error": null
}
}data is the complete job object as GET /v1/jobs/{id} returned it when the event was queued; created_at is when the event was queued. Deliveries carry Content-Type: application/json, User-Agent: INFRO-Webhooks/1, an INFRO-Signature header (omitted only on a per-request URL when the organization has no active console endpoint), and INFRO-Event-Id — the same value as id, convenient for log correlation. Redirects are not followed: your endpoint must answer 2xx itself.
Signature verification
Every delivery is signed: INFRO-Signature: t=1755950139,v1=5257a869.... t is the unix timestamp of this attempt (each retry is signed afresh), and v1 is the lowercase hex HMAC-SHA256 of the string {t}.{raw body} keyed by the endpoint's full signing secret, whsec_ prefix included. During a secret rotation the header carries one v1 per secret, current first — accept the delivery if any of them matches.
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:
pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
timestamp = next((v for k, v in pairs if k == "t"), None)
signatures = [v for k, v in pairs if k == "v1"] # two during a secret rotation
if not timestamp or not signatures:
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 any(hmac.compare_digest(expected, s) for s in signatures)
@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)import crypto from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.INFRO_WEBHOOK_SECRET as string; // whsec_...
const TOLERANCE_SECONDS = 300;
const seen = new Set<string>(); // use Redis or a unique index in production
function verify(rawBody: Buffer, header: string, secret: string): boolean {
let timestamp: string | undefined;
const signatures: string[] = []; // two during a secret rotation
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const key = part.slice(0, i);
const value = part.slice(i + 1);
if (key === "t") timestamp = value;
else if (key === "v1") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt)) return false;
if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
return signatures.some((signature) => {
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
// express.raw preserves the exact bytes INFRO signed
app.post(
"/hooks/infro",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.header("INFRO-Signature") ?? "";
if (!verify(req.body, header, SIGNING_SECRET)) {
res.sendStatus(400);
return;
}
const event = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) {
res.sendStatus(200); // delivery is at-least-once
return;
}
seen.add(event.id);
if (event.type === "job.succeeded") {
console.log("download", event.data.output.url); // enqueue, do not block
}
res.sendStatus(200);
},
);
app.listen(3000);import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
final signingSecret = Platform.environment['INFRO_WEBHOOK_SECRET']!;
const toleranceSeconds = 300;
final seen = <String>{}; // use Redis or a unique index in production
bool verify(String rawBody, String header, String secret) {
String? timestamp;
final signatures = <String>[]; // two during a secret rotation
for (final part in header.split(',')) {
final i = part.indexOf('=');
if (i <= 0) continue;
final key = part.substring(0, i);
final value = part.substring(i + 1);
if (key == 't') timestamp = value;
if (key == 'v1') signatures.add(value);
}
if (timestamp == null || signatures.isEmpty) return false;
final sentAt = int.tryParse(timestamp);
if (sentAt == null) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - sentAt).abs() > toleranceSeconds) return false;
final mac = Hmac(sha256, utf8.encode(secret));
final expected = mac.convert(utf8.encode('$timestamp.$rawBody')).toString();
// constant-time compare against each signature
var matched = false;
for (final signature in signatures) {
if (signature.length != expected.length) continue;
var diff = 0;
for (var i = 0; i < expected.length; i++) {
diff |= expected.codeUnitAt(i) ^ signature.codeUnitAt(i);
}
if (diff == 0) matched = true;
}
return matched;
}
Future<Response> handler(Request request) async {
final rawBody = await request.readAsString();
final header = request.headers['infro-signature'] ?? '';
if (!verify(rawBody, header, signingSecret)) return Response(400);
final event = jsonDecode(rawBody) as Map<String, dynamic>;
if (!seen.add(event['id'] as String)) return Response.ok(''); // duplicate
if (event['type'] == 'job.succeeded') {
final data = event['data'] as Map<String, dynamic>;
final output = data['output'] as Map<String, dynamic>;
print(output['url']); // enqueue, do not block
}
return Response.ok('');
}
Future<void> main() async {
await io.serve(handler, '0.0.0.0', 3000);
}Retries and delivery guarantees
- Any non-2xx status, a connection error, or a response slower than 10 seconds counts as a failed delivery.
- A 3xx is a failed delivery too; redirects are not followed. Failures retry on a widening schedule — 10 s, 30 s, 1 min, 2 min, 5 min, 10 min, 30 min, then 1, 2, 4, and 8 hours — for 12 attempts over roughly 16 hours, after which the delivery is marked failed. 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: it stays the same across retries of one delivery. Each endpoint that receives a job's event gets its own id. 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 register the tunnel URL as an endpoint in the console, or pass it aswebhook.urlon a single request (signed with your console endpoints' secrets, unsigned if you have none). - Generate real events cheaply: submit the shortest, lowest-resolution clip a model allows. Submitting and immediately canceling gives a
job.canceleddelivery, but the render may still run and be billed at your provider. - The console keeps a log of your latest 100 deliveries with the event type, job, status, attempt count, last response status or error, and next attempt time. There is no resend control; to iterate on handler code, capture one real delivery's raw body and headers and replay them locally.
- Test the rejection path too. Flip one character of the signature and confirm your endpoint answers 400 rather than processing the event.
- Register the tunnel as its own console 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.