ToAPIsToAPIs

异步任务 Webhook

接收并验证 ToAPIs 异步生成任务的最终成功和最终失败事件

国内用户请注意: 中国大陆用户请使用 https://toapis.cn 作为接口地址(Base URL)。文档示例中的 https://toapis.com 请替换为 https://toapis.cn。

ToAPIs 为异步图片和视频任务发送统一的最终状态事件。第一版只发送最终成功、最终失败和测试事件,不发送中间进度事件。

ToAPIs 提供至少一次投递,同一事件重试时 id 保持不变,但不保证恰好一次。接收方必须先持久化 event ID,再执行订单更新、发货等副作用;重复 event ID 应直接返回 2xx。

配置 Token

在 ToAPIs 控制台编辑 Token:

  1. 配置 HTTPS 默认 callback URL。
  2. 生成 32 字节签名密钥。密钥仅在生成或轮换时显示一次,请立即安全保存。
  3. 启用“任务 Webhook”并保存。
  4. 发送测试事件,确认接收端能验签并返回 2xx。

任务请求未传 callback_url 时,ToAPIs 使用 Token 默认地址。请求级 callback_url 只能覆盖路径和查询参数,协议、域名和端口必须与 Token 默认地址相同。

如果 Token 未启用 Webhook,或缺少默认地址/签名密钥,却在请求中传入 callback_url,ToAPIs 返回:

{
  "error": {
    "code": "webhook_not_configured",
    "message": "webhook_not_configured"
  }
}

事件类型与 Payload

task_type 只会是 image 或 video。result 与对应任务查询接口的标准化公开结果一致,不包含渠道 ID、上游任务 ID、上游密钥、内部凭证或 PrivateData。

用量, 计费与查询差异

图片和视频成功事件都可包含 data.usage, 包括计费量, 可用的模型 token 明细及工具次数. 字段定义与任务查询一致, 见任务用量字段. 视频事件还可包含 data.generation, Seedance 的字段及兼容用量见视频任务查询.

data.billing 可包含 status, credits, cost_usd. 金额为十进制字符串; pending 时省略金额, refunded 表示已确认净费用为零. 无法确认时省略对象, 不代表免费. Webhook 与任务查询使用同一确认条件: 已确认扣费的任务, 包括提交时一次扣清的固定计费任务, 两者均返回 settled 和实际金额; data.usage 返回 charge_type 等计费量时, 一定同时返回 settled 金额.

data.billing 字段取值

字段类型取值
statusstringpending: 待结算或退款尚未确认, 省略金额. settled: 已确认最终扣费, 返回实际金额, 免费任务金额为 "0". refunded: 已退款或失败后确认无净扣费, 金额均为 "0"
creditsstring实际扣除的 ToAPIs Credits, 十进制字符串, 例如 "100". 仅 settled 和 refunded 时返回
cost_usdstring同一笔费用的美元金额, 十进制字符串, 例如 "0.5". 返回条件同 credits, 已包含折扣和倍率, 不代表供应商成本

成功事件为 settled, 结算退回全部预扣时为 refunded; 失败事件在退款完成后发送, 为 refunded. 无法确认金额时省略整个 data.billing.

data.usage 字段取值

字段类型取值
charge_typestring计费方式: per_request 按次, per_second 按秒, per_token 按 token
requestsinteger计费次数, per_request 时返回. 图片请求的 n 不直接乘算次数
billed_tokensinteger计价 token 数, per_token 时返回
output_secondsinteger参与计费的输出视频时长, 整数秒, per_second 时返回
input_secondsinteger参与计费的输入视频时长, 整数秒, 为零时省略
input_imagesinteger扣除免费额度后参与计费的输入图片数, 为零时省略
input_tokens, output_tokens, total_tokensinteger模型报告的输入, 输出和总 token 数
input_tokens_detailsobject图片输入明细: text_tokens, image_tokens, cached_tokens 及 cached_tokens_details.text_tokens, cached_tokens_details.image_tokens. 缓存 token 已包含在输入总数中
output_tokens_detailsobject图片输出明细: text_tokens, image_tokens; 文本与思考同价且未细分时另返回 text_or_reasoning_tokens, 不计入 text_tokens
prompt_tokens, completion_tokensinteger视频模型报告的 token 数
tool_usageobject工具次数: web_search, image_search, grounding_queries, 按实际发生返回

charge_type 到 input_images 是计费量, 只与 data.billing.status=settled 同时返回. token 明细和工具次数按供应商实际提供情况独立返回. 未返回的字段直接省略, 不补零.

Webhook 是事件创建时的快照, 任务查询读取最新已确认状态. 缺少用量或金额不会等待补齐, 不延迟投递或改变重试; 已创建事件不回填, 重试沿用原始事件负载.

事件结构和 api_version: "v1" 保持不变. Webhook 使用 data.task_id, SUCCESS / FAILURE 和字符串进度 "100%"; 查询使用 id, 小写状态和整数进度. 时间字段与公开 generation.task 查询同名同口径, 均为 Unix 秒: data.created_at 为任务提交时间, data.finished_at 为结束时间, 成功时另返回 data.completed_at 和 data.expires_at (结果过期时间, 结束时间加 24 小时), 缺少值的字段直接省略. 原 data.submitted_at 已由 data.created_at 取代, 不再返回. 事件顶层 created_at 是事件创建时间, 不是任务提交时间. 两者的媒体 URL 可能因访问策略不同而不同.

Seedance 视频成功事件示例

以下样例用于展示字段, 不代表模型的当前价格或固定计费方式. 实际字段和金额以本次任务返回为准.

Seedance 成功事件的 data.result.data[] 也可包含 fallback_url 和 fallback_last_frame_url. 优先使用 url 和 last_frame_url, 原地址无法访问时才使用对应备用地址. 备用字段缺省不表示任务失败. 事件保留创建时的地址快照, 后续副本变化不会改写已创建事件或其重试负载. 字段定义和完整使用规则见视频任务查询.

{
  "id": "evt_seedance_example",
  "type": "generation.completed",
  "api_version": "v1",
  "created_at": 1792400061,
  "data": {
    "task_id": "task_seedance_example",
    "client_business_id": "order_123",
    "task_type": "video",
    "model": "seedance-2-global",
    "status": "SUCCESS",
    "progress": "100%",
    "result": {
      "type": "video",
      "data": [
        {
          "url": "https://cdn.example/result.mp4",
          "fallback_url": "https://files.toapis.com/videos/task_seedance_example/result.mp4",
          "format": "mp4",
          "last_frame_url": "https://cdn.example/last-frame.jpg",
          "fallback_last_frame_url": "https://files.toapis.com/videos/task_seedance_example/last-frame.jpg"
        }
      ]
    },
    "usage": {
      "charge_type": "per_token",
      "billed_tokens": 87300,
      "completion_tokens": 87300,
      "total_tokens": 87300
    },
    "billing": {
      "status": "settled",
      "credits": "87.3",
      "cost_usd": "0.4365"
    },
    "generation": {
      "duration": 5,
      "resolution": "720p",
      "ratio": "16:9",
      "generate_audio": true,
      "usage": {
        "completion_tokens": 87300,
        "total_tokens": 87300
      }
    },
    "error": null,
    "created_at": 1792400000,
    "finished_at": 1792400060,
    "completed_at": 1792400060,
    "expires_at": 1792486460
  }
}

generation.completed

{
  "id": "evt_01abcdef",
  "type": "generation.completed",
  "api_version": "v1",
  "created_at": 1783953302,
  "data": {
    "task_id": "tsk_01abcdef",
    "client_business_id": "order_123",
    "task_type": "image",
    "model": "model-name",
    "status": "SUCCESS",
    "progress": "100%",
    "result": {
      "type": "image",
      "data": [
        {
          "url": "https://cdn.example/result.png"
        }
      ]
    },
    "usage": {
      "charge_type": "per_request",
      "requests": 1
    },
    "billing": {
      "status": "settled",
      "credits": "100",
      "cost_usd": "0.5"
    },
    "error": null,
    "created_at": 1783953000,
    "finished_at": 1783953300,
    "completed_at": 1783953300,
    "expires_at": 1784039700
  }
}

generation.failed

错误标识的含义与处理建议见错误码速查. Webhook 的 data.error 不保证包含任务查询中的 reason 字段.

只有内部重试已经结束、最终计费或退款已经完成时,ToAPIs 才会发送失败事件。暂时性失败不会触发 generation.failed。

{
  "id": "evt_01failure",
  "type": "generation.failed",
  "api_version": "v1",
  "created_at": 1783953302,
  "data": {
    "task_id": "tsk_01failure",
    "client_business_id": "order_456",
    "task_type": "video",
    "model": "model-name",
    "status": "FAILURE",
    "progress": "100%",
    "result": null,
    "billing": {
      "status": "refunded",
      "credits": "0",
      "cost_usd": "0"
    },
    "error": {
      "code": "generation_failed",
      "message": "Generation failed after retries"
    },
    "created_at": 1783953000,
    "finished_at": 1783953300
  }
}

endpoint.test

{
  "id": "evt_01test",
  "type": "endpoint.test",
  "api_version": "v1",
  "created_at": 1783953302,
  "data": {
    "task_id": "endpoint_test",
    "task_type": "image",
    "status": "SUCCESS",
    "progress": "100%",
    "result": {
      "message": "ToAPIs webhook endpoint test"
    },
    "error": null,
    "created_at": 0
  }
}

请求头与签名

User-Agent: ToAPIs-Webhook/1.0
X-ToAPIs-Webhook-Id: evt_01abcdef
X-ToAPIs-Webhook-Timestamp: 1783953302
X-ToAPIs-Webhook-Signature: v1=<current-signature>[,v1=<previous-signature>]

签名原文必须使用未经解析、未经重新序列化的原始请求体:

event_id + "." + timestamp + "." + raw_request_body

算法为 HMAC-SHA256,输出为小写十六进制。首次收到事件时,应拒绝与本地时间相差超过 5 分钟的时间戳。重试使用实际发送时间重新签名,但 event ID 不变。

密钥轮换后 24 小时内,ToAPIs 同时发送当前密钥和上一密钥的两个 v1 签名。接收方应拆分逗号分隔的签名,并接受任意一个有效值。

Python

import hashlib
import hmac
import time

def verify_toapis(headers, raw_body: bytes, secrets: list[str]) -> bool:
    event_id = headers["X-ToAPIs-Webhook-Id"]
    timestamp = int(headers["X-ToAPIs-Webhook-Timestamp"])
    if abs(int(time.time()) - timestamp) > 300:
        return False

    signed = f"{event_id}.{timestamp}.".encode() + raw_body
    received = [
        value.strip()[3:]
        for value in headers["X-ToAPIs-Webhook-Signature"].split(",")
        if value.strip().startswith("v1=")
    ]
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, signature) for signature in received):
            return True
    return False

Node.js

import crypto from 'node:crypto';

export function verifyToAPIs(headers, rawBody, secrets) {
  const eventId = headers['x-toapis-webhook-id'];
  const timestamp = Number(headers['x-toapis-webhook-timestamp']);
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false;
  }

  const signed = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), rawBody]);
  const received = headers['x-toapis-webhook-signature']
    .split(',')
    .map((value) => value.trim())
    .filter((value) => value.startsWith('v1='))
    .map((value) => value.slice(3));

  return secrets.some((secret) => {
    const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
    return received.some((signature) =>
      expected.length === signature.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)),
    );
  });
}

Go

package webhook

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

func VerifyToAPIs(eventID, timestamp, signature string, body []byte, secrets []string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || time.Now().Unix()-ts > 300 || ts-time.Now().Unix() > 300 {
        return false
    }
    signed := append([]byte(eventID+"."+timestamp+"."), body...)
    received := strings.Split(signature, ",")
    for _, secret := range secrets {
        mac := hmac.New(sha256.New, []byte(secret))
        _, _ = mac.Write(signed)
        expected, _ := hex.DecodeString(hex.EncodeToString(mac.Sum(nil)))
        for _, value := range received {
            value = strings.TrimSpace(value)
            if !strings.HasPrefix(value, "v1=") {
                continue
            }
            actual, err := hex.DecodeString(strings.TrimPrefix(value, "v1="))
            if err == nil && hmac.Equal(expected, actual) {
                return true
            }
        }
    }
    return false
}

投递、重试与安全

  • 首次投递立即执行;失败后约在 10 秒、30 秒、2 分钟、10 分钟、1 小时、6 小时和 24 小时后重试。
  • 只有 2xx 被视为成功。3xx 不会自动跟随,4xx 和 5xx 都会按计划重试。
  • 单次 HTTP 请求超时为 10 秒,ToAPIs 最多读取 4 KB 响应体用于脱敏后的诊断。
  • callback URL 必须使用 HTTPS,禁止 userinfo、fragment、私网、回环、链路本地、保留地址和非法端口。
  • 每次投递都会重新执行 DNS/IP 与 SSRF 校验,并把连接固定到已校验的公网 IP,避免 DNS rebinding。
  • 回调延迟或最终失败时,请使用任务查询接口兜底;轮询应遵守异步任务速率限制。
  • 结果 URL 可能具有有效期,请在收到事件后及时下载和保存所需文件。

Seedance 真人认证的 callback_url、支付 Webhook 和 OAuth callback 保持原有协议,不属于异步生成任务 Webhook。

On this page