> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lumenfall.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive notifications when asynchronous operations complete

Lumenfall can send webhook notifications to your server when asynchronous operations like video generation complete or fail. Webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

## Setup

Provide a `webhook_url` when creating an asynchronous request. Lumenfall will POST to that URL when the operation completes or fails.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.lumenfall.ai/openai/v1/videos \
    -H "Authorization: Bearer $LUMENFALL_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "veo-3.1-fast",
      "prompt": "A capybara swimming in a pool",
      "webhook_url": "https://yourapp.com/hooks/lumenfall"
    }'
  ```

  ```python Python theme={null}
  from openai import OpenAI

  client = OpenAI(
      api_key="your-lumenfall-api-key",
      base_url="https://api.lumenfall.ai/openai/v1"
  )

  # Pass webhook_url as an extra body parameter
  response = client.videos.create(
      model="veo-3.1-fast",
      prompt="A capybara swimming in a pool",
      extra_body={
          "webhook_url": "https://yourapp.com/hooks/lumenfall",
      },
  )
  ```

  ```typescript JavaScript / TypeScript theme={null}
  import OpenAI from "openai";

  const client = new OpenAI({
    apiKey: "your-lumenfall-api-key",
    baseURL: "https://api.lumenfall.ai/openai/v1",
  });

  // Pass webhook_url as an extra body parameter
  const response = await client.videos.create({
    model: "veo-3.1-fast",
    prompt: "A capybara swimming in a pool",
    // @ts-expect-error Lumenfall extension
    webhook_url: "https://yourapp.com/hooks/lumenfall",
  });
  ```
</CodeGroup>

A signing secret is automatically provisioned for your organization the first time you use a webhook URL. Retrieve it with the [Get webhook secret](/api-reference/webhooks/secret) endpoint.

## Webhook format

Each webhook delivery is a `POST` request with a JSON body and Standard Webhooks signature headers:

| Header              | Description                                    |
| ------------------- | ---------------------------------------------- |
| `webhook-id`        | Unique message identifier (`msg_<request_id>`) |
| `webhook-timestamp` | Unix timestamp in seconds                      |
| `webhook-signature` | `v1,<base64-hmac-sha256-signature>`            |

### Event types

| Event             | Description                            |
| ----------------- | -------------------------------------- |
| `video.completed` | Video generation finished successfully |
| `video.failed`    | Video generation failed                |
| `video.cancelled` | Video generation was cancelled         |

### Example payload

```json theme={null}
{
  "type": "video.completed",
  "data": {
    "object": "video.generation",
    "id": "req_abc123",
    "model": "veo-3.1-fast",
    "status": "completed",
    "output": {
      "url": "https://media.lumenfall.ai/abc123.mp4"
    }
  }
}
```

## Retries

Failed deliveries are retried up to 3 times with increasing delays (5s, 15s, 45s). Each attempt has a 30-second timeout. A delivery is considered successful when your server responds with a `2xx` status code.

## URL restrictions

Webhook URLs are validated when you submit your request. If the URL does not meet the requirements below, the request is rejected with a `400` error. URLs are also re-validated before each delivery attempt.

| Rule                  | Details                                                                                                                           |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **HTTPS required**    | Only `https://` URLs are accepted. Plain `http://` is rejected.                                                                   |
| **No IP addresses**   | Bare IPv4 (`https://10.0.0.1/...`) and IPv6 (`https://[::1]/...`) addresses are rejected. Use a domain name.                      |
| **Port restrictions** | Only port 443 (default HTTPS) and 8443 are allowed.                                                                               |
| **Blocked hostnames** | `localhost`, cloud metadata endpoints (`metadata.google.internal`, `metadata.goog`), and similar internal hostnames are rejected. |

These restrictions prevent [Server-Side Request Forgery (SSRF)](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). If you believe a valid URL is being incorrectly rejected, contact [support](mailto:support@lumenfall.ai).

## Verifying signatures

Verify the `webhook-signature` header to confirm deliveries are authentic and have not been tampered with. The signed content is `${webhook-id}.${webhook-timestamp}.${body}`, signed with the base64-decoded portion of your `whsec_`-prefixed secret.

Retrieve your secret with the [Get webhook secret](/api-reference/webhooks/secret) endpoint. Store it securely - treat it like an API key.

<CodeGroup>
  ```bash cURL theme={null}
  # Retrieve your signing secret
  curl https://api.lumenfall.ai/v1/webhooks/secret \
    -H "Authorization: Bearer $LUMENFALL_API_KEY"
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import base64
  import time

  def verify_webhook(body: str, headers: dict, secret: str) -> bool:
      msg_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      signature = headers["webhook-signature"]

      # Reject old timestamps (replay protection)
      if abs(time.time() - int(timestamp)) > 300:
          raise ValueError("Timestamp too old")

      # Compute expected signature
      key = base64.b64decode(secret.removeprefix("whsec_"))
      to_sign = f"{msg_id}.{timestamp}.{body}".encode()
      expected = base64.b64encode(
          hmac.new(key, to_sign, hashlib.sha256).digest()
      ).decode()

      # Compare (timing-safe)
      _, sig = signature.split(",", 1)
      return hmac.compare_digest(expected, sig)
  ```

  ```typescript JavaScript / TypeScript theme={null}
  import crypto from "node:crypto";

  function verifyWebhook(
    body: string,
    headers: Record<string, string>,
    secret: string
  ): boolean {
    const msgId = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signature = headers["webhook-signature"];

    // Reject old timestamps (replay protection)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp)) > 300) {
      throw new Error("Timestamp too old");
    }

    // Compute expected signature
    const key = Buffer.from(secret.replace("whsec_", ""), "base64");
    const toSign = `${msgId}.${timestamp}.${body}`;
    const expected = crypto
      .createHmac("sha256", key)
      .update(toSign)
      .digest("base64");

    // Compare (timing-safe)
    const [, sig] = signature.split(",");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  }
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"fmt"
  	"math"
  	"strconv"
  	"strings"
  	"time"
  )

  func verifyWebhook(body string, headers map[string]string, secret string) (bool, error) {
  	msgID := headers["webhook-id"]
  	timestamp := headers["webhook-timestamp"]
  	signature := headers["webhook-signature"]

  	// Reject old timestamps
  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return false, err
  	}
  	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
  		return false, fmt.Errorf("timestamp too old")
  	}

  	// Compute expected signature
  	keyStr := strings.TrimPrefix(secret, "whsec_")
  	key, err := base64.StdEncoding.DecodeString(keyStr)
  	if err != nil {
  		return false, err
  	}
  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(fmt.Sprintf("%s.%s.%s", msgID, timestamp, body)))
  	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

  	// Compare
  	parts := strings.SplitN(signature, ",", 2)
  	if len(parts) != 2 {
  		return false, fmt.Errorf("invalid signature format")
  	}
  	return hmac.Equal([]byte(expected), []byte(parts[1])), nil
  }
  ```

  ```ruby Ruby theme={null}
  require "openssl"
  require "base64"

  def verify_webhook(body, headers, secret)
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signature = headers["webhook-signature"]

    # Reject old timestamps
    raise "Timestamp too old" if (Time.now.to_i - timestamp.to_i).abs > 300

    # Compute expected signature
    key = Base64.decode64(secret.delete_prefix("whsec_"))
    to_sign = "#{msg_id}.#{timestamp}.#{body}"
    expected = Base64.strict_encode64(
      OpenSSL::HMAC.digest("sha256", key, to_sign)
    )

    # Compare (timing-safe)
    _, sig = signature.split(",", 2)
    OpenSSL.secure_compare(expected, sig)
  end
  ```
</CodeGroup>

<Warning>
  Always verify signatures before processing webhook payloads. Reject any delivery where the timestamp is more than 5 minutes old to prevent replay attacks.
</Warning>
