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

> ## Agent Instructions
> Five API is a video generation REST API. Base URL: https://api.fiveapi.ai. Authenticate with the header Authorization: Bearer <API key>; read the key from the FIVEAPI_API_KEY environment variable and never hard-code it.
> Every generation is an asynchronous task: POST /v1/{brand}/{version}/{task} returns a queued task, then poll GET /v1/tasks/{task_id} or receive a signed webhook, and download output.video.url before expires_at (7 days).
> The OpenAPI spec is at https://docs.fiveapi.ai/api-reference/openapi.yaml. An installable agent skill: npx skills add https://docs.fiveapi.ai

# Webhooks

> Get notified when a task completes, fails or is cancelled, and verify that the notification came from Five API.

Instead of polling, let Five API call you. When a task reaches a terminal status, Five API sends a signed `POST` to your endpoint with the full task object.

## Set up an endpoint

You can send events to two places:

* **A default endpoint for the account.** Set it on the **Webhooks** page of the [console](https://fiveapi.ai). It receives the events of every task that has no `webhook_url` of its own.
* **A per-task endpoint.** Pass `webhook_url` when you create the task. It overrides the default endpoint for that task.

```json theme={null}
{
  "prompt": "a red kite rising over a windy beach at sunset",
  "duration": 5,
  "resolution": "480p",
  "webhook_url": "https://hooks.example.com/fiveapi",
  "metadata": { "order_id": "ord_8812" }
}
```

Endpoints must use `https` and be reachable from the public internet. On the **Webhooks** page you can also copy your signing secret, rotate it, send a signed test event to your endpoint, and see every delivery attempt.

## Events

| `type` | Sent when | What to do |
| - | - | - |
| `task.completed` | The video is ready and `credits.charged` is final | Download `data.output.video.url` |
| `task.failed` | The task failed; its hold was released | Read `data.error`; resubmit if `data.error.retryable` is true |
| `task.cancelled` | The task was cancelled; its hold was released | Update your records |

Each event carries the full [task object](https://testweb2-docs-openapi.test765.com/concepts/tasks.md#the-task-object) in `data`, including your `metadata`:

```json theme={null}
{
  "id": "evt_01J9X7Z0K7Q8W3F2P1N6M5V4R3",
  "object": "event",
  "type": "task.completed",
  "created_at": "2026-10-23T06:23:05.004Z",
  "data": {
    "id": "task_01J9X7Z0K7Q8W3F2P1N6M5V4R3",
    "object": "task",
    "model": "wan/v3.0-spark/image-to-video",
    "task": "image-to-video",
    "status": "completed",
    "input": { "image_url": "https://assets.example.com/inputs/portrait.jpg", "prompt": "slow push-in, soft morning light", "duration": 5, "resolution": "720p" },
    "output": {
      "video": {
        "url": "https://openassets.fiveapi.ai/x2v/results/551fe9fa/openapi_1793171336.mp4",
        "content_type": "video/mp4",
        "file_size": 4823119,
        "width": 720,
        "height": 1280,
        "duration": 5.04,
        "fps": 24
      }
    },
    "error": null,
    "credits": { "estimated": 60, "charged": 60 },
    "metadata": { "order_id": "ord_8812" },
    "created_at": "2026-10-23T06:21:02.127Z",
    "started_at": "2026-10-23T06:21:40.512Z",
    "completed_at": "2026-10-23T06:23:05.004Z",
    "expires_at": "2026-10-30T06:23:05.004Z"
  }
}
```

The request carries these headers:

| Header | Value |
| - | - |
| `webhook-id` | The event id (`evt_...`). It equals `id` in the body and **stays the same across retries**. |
| `webhook-timestamp` | Unix time in seconds when the event was signed. |
| `webhook-signature` | `v1,<base64 signature>`. During a secret rotation it carries several values separated by spaces. |
| `Content-Type` | `application/json` |
| `User-Agent` | `OpenAPI-Webhooks/1.0` |

## Verify the signature

Five API signs every event with your account's signing secret, following the [Standard Webhooks](https://www.standardwebhooks.com) specification. Always verify the signature before you act on an event.

1. Take the **raw** request body exactly as received, before any JSON parsing.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Decode the part of your secret after the `whsec_` prefix from base64. That is the HMAC key.
4. Compute HMAC-SHA256 of the signed content and encode it in base64.
5. Compare it, in constant time, with each `v1,` value of `webhook-signature`. One match is enough.
6. Reject events whose `webhook-timestamp` is more than 5 minutes away from your clock.

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


def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.split("_", 1)[1])
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(expected, s[3:])
        for s in signatures.split(" ")
        if s.startswith("v1,")
    )
```

```javascript Node.js theme={null}
const crypto = require("node:crypto");

function verify(secret, headers, rawBody, tolerance = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
  const key = Buffer.from(secret.slice(secret.indexOf("_") + 1), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest("base64");
  return signatures.split(" ").some(
    (s) =>
      s.startsWith("v1,") &&
      s.length - 3 === expected.length &&
      crypto.timingSafeEqual(Buffer.from(s.slice(3)), Buffer.from(expected)),
  );
}
```

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

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

// Verify checks the signature of a Five API webhook. secret is the whsec_... value from the console.
func Verify(secret, id, timestamp, signatures string, rawBody []byte) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	if age := time.Now().Unix() - ts; age > 300 || age < -300 {
		return false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(rawBody)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, s := range strings.Split(signatures, " ") {
		if strings.HasPrefix(s, "v1,") && hmac.Equal([]byte(s[3:]), []byte(expected)) {
			return true
		}
	}
	return false
}
```

> [!TIP]
>
> The open-source [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks) for Python, JavaScript, Go, Java, PHP, Ruby and more verify this format out of the box. Pass them your `whsec_...` secret and the three headers.

### Test your verifier

Feed these exact values to your verifier. It must accept the signature, and reject it as soon as one byte of the body, the id or the timestamp changes. Disable the timestamp check for this test, because the timestamp is fixed.

| Input | Value |
| - | - |
| Secret | `whsec_c2FtcGxlLXdlYmhvb2stc2VjcmV0LTI0` |
| `webhook-id` | `evt_01J9X7Z0K7Q8W3F2P1N6M5V4R3` |
| `webhook-timestamp` | `1792736585` |
| Expected `webhook-signature` | `v1,RlzoRMsvgGLfVsE6NqX4N5CHAfiWBABarbN8HusOBnE=` |

Raw body: 877 bytes, one line, no trailing newline.

```text theme={null}
{"id":"evt_01J9X7Z0K7Q8W3F2P1N6M5V4R3","object":"event","type":"task.completed","created_at":"2026-10-23T06:23:05.004Z","data":{"id":"task_01J9X7Z0K7Q8W3F2P1N6M5V4R3","object":"task","model":"wan/v3.0-spark/image-to-video","task":"image-to-video","status":"completed","input":{"image_url":"https://assets.example.com/inputs/portrait.jpg","prompt":"slow push-in, soft morning light","duration":5,"resolution":"720p"},"output":{"video":{"url":"https://cdn.example.com/openapi/x2v/result/2026/10/23/oa_1761200462127_k3m9p2q7.mp4","content_type":"video/mp4","file_size":4823119,"width":720,"height":1280,"duration":5.0,"fps":24}},"error":null,"credits":{"estimated":15,"charged":15},"metadata":{"order_id":"ord_8812"},"created_at":"2026-10-23T06:21:02.127Z","started_at":"2026-10-23T06:21:40.512Z","completed_at":"2026-10-23T06:23:05.004Z","expires_at":"2026-10-30T06:23:05.004Z"}}
```

## Handle events

Answer with any `2xx` within **10 seconds**, then do the work. A slow handler times out and the event is sent again.

```javascript Node.js (Express) theme={null}
const express = require("express");
const app = express();

// express.raw keeps the exact bytes the signature was computed over
app.post("/fiveapi", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(process.env.FIVEAPI_WEBHOOK_SECRET, req.headers, req.body)) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200); // acknowledge first
  if (alreadyProcessed(event.id)) return; // retries reuse the same webhook-id
  setImmediate(() => handleEvent(event)); // then do the work
});

function handleEvent(event) {
  const task = event.data;
  switch (event.type) {
    case "task.completed":
      return saveVideo(task.id, task.output.video.url, task.metadata);
    case "task.failed":
      return markFailed(task.id, task.error); // resubmit when task.error.retryable
    case "task.cancelled":
      return markCancelled(task.id);
  }
}
```

```python Python (Flask) theme={null}
import json
import os
import threading

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/fiveapi")
def fiveapi_webhook():
    raw_body = request.get_data()  # the exact bytes, before JSON parsing
    if not verify(os.environ["FIVEAPI_WEBHOOK_SECRET"], request.headers, raw_body):
        abort(400)
    event = json.loads(raw_body)
    if not already_processed(event["id"]):  # retries reuse the same webhook-id
        threading.Thread(target=handle_event, args=(event,)).start()
    return "", 200
```

## Delivery and retries

| | |
| - | - |
| Success | Any `2xx` answer within 10 seconds (5 seconds to connect) |
| Retries | After a non-`2xx` answer, a timeout or a connection error: after 1 minute, 5 minutes, 30 minutes, then every 2 hours, 9 attempts in total over about 11 hours |
| Redirects | Not followed. A `3xx` answer stops the delivery at once |
| After the last attempt | The delivery stops. Resend it from the **Webhooks** page of the console |
| Ordering | Not guaranteed across tasks. Each task sends one event per terminal status, and retries repeat the same `webhook-id` |

## Best practices

* **Verify every event** with the signing secret, over the raw body.
* **De-duplicate on `webhook-id`.** A retry delivers the same event again.
* **Acknowledge fast** and process asynchronously.
* **Carry your own ids in `metadata`**, so a handler can route an event without a lookup.
* **Treat the task as the source of truth.** If an event seems late or missing, call `GET /v1/tasks/{task_id}`.
* **Rotate the secret** from the console if it leaks. During the rotation the signature header carries a value for each secret, so a verifier that accepts any matching `v1,` value keeps working.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.