# Webhooks

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

Source: https://infro.io/docs/api/webhooks

> **Note:** 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](https://infro.io/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](https://infro.io/docs/modalities/video) costs you one inbound request instead of a hundred outbound ones.

Configure endpoints for your organization in the [console](https://dash.infro.io) — 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

```json
{
  "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.url` (string, required) — 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.events` (array) — Which of `job.succeeded`, `job.failed`, and `job.canceled` to deliver. Defaults to all three. Subscribing to only `job.succeeded` is 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](https://infro.io/docs/observability/alerts).

## 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",
    "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}`](https://infro.io/docs/api/jobs) 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.

**Python**

```python
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)
```

**TypeScript**

```typescript
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);
```

**Dart**

```dart
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}`](https://infro.io/docs/api/jobs).
- 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`.

> **Warning:** 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 as `webhook.url` on 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.canceled` delivery, 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](https://infro.io/docs/authentication), and the status codes a failed job reports in [Errors](https://infro.io/docs/api/errors).
