Fileverity API 文档
示例代码
Fileverity API 的完整 Node.js 和 Python 程序:检测图片、检测大型 PDF、检测 AI 撰写的文本以及校验 Webhook 签名。
本页提供 Fileverity API 的四个完整程序,每个程序都有 Node.js 18 或更高版本和 Python 3.9 或更高版本两种实现:检测图片并等待结果、检测大型 PDF、检测 AI 撰写的文本,以及接收 Webhook 并校验签名。复制其中一个,设置 FILEVERITY_API_KEY 后运行即可。
这些程序直接调用 HTTPS API,Node.js 使用内置的 fetch,Python 使用 requests 包,因此您只需要一个 HTTP 客户端。其中的调用、字段和状态与文件、分析、文本检测和 Webhooks 中的描述一致。
| 程序 | 功能 | 调用 |
|---|---|---|
| 检测图片 | 上传图片,等待结果,并输出摘要和报告 URL。 | POST /files, POST /analyses, GET /analyses/{id} |
| 检测大型 PDF | 通过预签名 URL 上传文档,轮询直到检测完成,并保存 PDF 报告。 | POST /files, GET /files/{id}, POST /analyses, GET /analyses/{id}, GET /analyses/{id}/report.pdf |
| 检测 AI 撰写的文本 | 发送文本文件,并输出判定结果、分数以及每个 AI 撰写的段落。 | POST /text/detections |
| 接收 Webhook | 校验每次投递的签名,忽略重复投递,并处理事件。 | 您的端点,由 analysis.completed、analysis.failed 和 balance.low 事件调用 |
运行之前
- 在控制台的 API 密钥中创建一个 Test 密钥。测试密钥返回固定的示例结果,永不扣费,因此所有程序都可以免费运行。如需检测真实文件,请创建 Live 密钥,程序无需修改。
- Node.js:将每个程序保存为
.mjs文件,这样即可使用import和顶层await,然后用node运行。无需安装任何依赖。 - Python:运行
pip install requests。Webhook 接收程序还需要运行pip install flask。
export FILEVERITY_API_KEY=fv_test_...$env:FILEVERITY_API_KEY = "fv_test_..."程序如何处理密钥、错误和重试
调用 API 的三个程序共用相同的 request 和 api 函数。可以原样复制到您自己的代码中。
| 方面 | 程序的做法 |
|---|---|
| 密钥 | 从 FILEVERITY_API_KEY 读取,绝不写入代码。请从您的服务器调用 API,不要在浏览器代码中调用。 |
| 幂等性 | 每个 POST 请求都会发送 Idempotency-Key,每个请求使用一个新的 UUID,并在每次重试时复用它,因此重试不会让同一次检测运行或计费两次。若要让整个脚本重新运行时也安全,请改为根据您自己的记录 ID 生成该值。参见幂等性。 |
| 错误 | 非 2xx 的响应会包含 error.type、error.code、error.message、error.param 和 error.request_id。程序会以 code: message (request request_id) 的形式报错并停止。请以 code 为判断依据,并在联系技术支持时提供请求 ID。参见错误。 |
| 重试 | 网络故障、5xx 状态码(internal_error、analysis_failed、service_unavailable)、rate_limited(429)和 idempotency_in_progress(409)都会重试,最多尝试 5 次。若响应带有 retry-after 响应头,则等待该时长,否则依次等待 2、4、8 和 16 秒。其他所有 4xx 错误会立即停止。pending_files_limit 同样是 429,但不带 retry-after,因此不会重试。 |
| 速率限制 | 当响应头显示 x-ratelimit-remaining-requests: 0 时,程序会先等待 x-ratelimit-reset-requests 秒再发起下一次调用,从而避免触发 429。参见速率限制与 HTTP 头。 |
| 超时 | Python 程序的单次请求超时时间为 90 秒,长于 Prefer: wait 最长可保持请求的 60 秒。 |
检测图片并等待结果
程序以 multipart 方式上传图片,并通过 Prefer: wait=60 创建分析,使请求保持等待,直到结果就绪,然后输出摘要和报告 URL。如果等待超时而结果仍未就绪,API 会返回 202 和 status: processing,程序随后每 3 秒轮询一次 GET /analyses/{id}。参见等待、轮询或使用 Webhook。
将其保存为 check-image.mjs 或 check_image.py,然后运行 node check-image.mjs photo.jpg 或 python check_image.py photo.jpg。multipart 上传最大支持 4 MB 的图片。更大的图片(最大 10 MB)请使用下一个程序中的预签名上传,并指定该图片的内容类型,例如 image/jpeg。
import { randomUUID } from "node:crypto";import { readFile } from "node:fs/promises";import { basename } from "node:path"; const base = "https://api.fileverity.com/v1";const key = process.env.FILEVERITY_API_KEY;const path = process.argv[2];if (!key || !path) throw new Error("Usage: FILEVERITY_API_KEY=... node check-image.mjs photo.jpg"); const retryable = ["rate_limited", "idempotency_in_progress"];const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); async function request(url, init) { for (let attempt = 1; ; attempt++) { let response = null; let problem; try { response = await fetch(url, init); if (response.ok) { const remaining = response.headers.get("x-ratelimit-remaining-requests"); const reset = Number(response.headers.get("x-ratelimit-reset-requests")); if (remaining === "0") await sleep(reset); return response; } problem = (await response.json().catch(() => ({}))).error ?? {}; } catch (error) { problem = { code: "network_error", message: error.message }; } const status = response?.status ?? 0; const retry = status === 0 || status >= 500 || retryable.includes(problem.code); if (!retry || attempt === 5) { const { code = status, message = "Request failed", request_id: id = "unknown" } = problem; throw new Error(`${code}: ${message} (request ${id})`); } await sleep(Number(response?.headers.get("retry-after")) || 2 ** attempt); }} async function api(method, route, { headers, body } = {}) { const auth = { Authorization: `Bearer ${key}` }; const response = await request(base + route, { method, headers: { ...auth, ...headers }, body }); return response.json();} const form = new FormData();form.append("file", new Blob([await readFile(path)]), basename(path));const file = await api("POST", "/files", { headers: { "Idempotency-Key": randomUUID() }, body: form,}); let analysis = await api("POST", "/analyses", { headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID(), Prefer: "wait=60", }, body: JSON.stringify({ model: "fvimg-2", file: file.id }),});while (analysis.status === "processing") { await sleep(3); analysis = await api("GET", `/analyses/${analysis.id}`);} if (analysis.status !== "succeeded") { throw new Error(`The check ${analysis.status}: ${analysis.error?.message}`);}const { level, action, assessment, ai, edit } = analysis.summary;console.log(`${analysis.id} ${analysis.status}`);console.log(`level ${level}, action ${action}, assessment ${assessment}`);console.log(`AI generation: ${ai}, editing: ${edit}`);console.log(`Report: ${analysis.report_url}`);import osimport sysimport timeimport uuid import requests BASE = "https://api.fileverity.com/v1"KEY = os.environ.get("FILEVERITY_API_KEY")if not KEY or len(sys.argv) < 2: sys.exit("Usage: FILEVERITY_API_KEY=... python check_image.py photo.jpg") RETRYABLE = ("rate_limited", "idempotency_in_progress") def request(method, url, **kwargs): for attempt in range(1, 6): try: response = requests.request(method, url, timeout=90, **kwargs) except requests.RequestException as error: response, problem = None, {"code": "network_error", "message": str(error)} else: if response.ok: if response.headers.get("x-ratelimit-remaining-requests") == "0": time.sleep(int(response.headers["x-ratelimit-reset-requests"])) return response try: problem = response.json()["error"] except (ValueError, KeyError, TypeError): problem = {} status = response.status_code if response is not None else 0 retry = status == 0 or status >= 500 or problem.get("code") in RETRYABLE if not retry or attempt == 5: code = problem.get("code", status) message = problem.get("message", "Request failed") request_id = problem.get("request_id", "unknown") raise RuntimeError(f"{code}: {message} (request {request_id})") wait = response.headers.get("retry-after") if response is not None else None time.sleep(int(wait or 2**attempt)) def api(method, route, headers=None, **kwargs): headers = {"Authorization": f"Bearer {KEY}", **(headers or {})} return request(method, BASE + route, headers=headers, **kwargs).json() with open(sys.argv[1], "rb") as source: data = source.read()upload = {"file": (os.path.basename(sys.argv[1]), data)}file = api("POST", "/files", headers={"Idempotency-Key": str(uuid.uuid4())}, files=upload) analysis = api( "POST", "/analyses", headers={"Idempotency-Key": str(uuid.uuid4()), "Prefer": "wait=60"}, json={"model": "fvimg-2", "file": file["id"]},)while analysis["status"] == "processing": time.sleep(3) analysis = api("GET", f"/analyses/{analysis['id']}") if analysis["status"] != "succeeded": problem = analysis["error"] or {} sys.exit(f"The check {analysis['status']}: {problem.get('message')}")summary = analysis["summary"]print(f"{analysis['id']} {analysis['status']}")print(f"level {summary['level']}, action {summary['action']}, assessment {summary['assessment']}")print(f"AI generation: {summary['ai']}, editing: {summary['edit']}")print(f"Report: {analysis['report_url']}")an_8Hk2pQ4vN9sL1xR7cW3mY6zB succeededlevel L2, action accept, assessment authentic_untouchedAI generation: very_low, editing: noneReport: https://fileverity.com/verify/FV-7K2M-9QXD-4T8Rsummary 是图片检测稳定且机器可读的判定结果,包含 level、action、assessment、ai 和 edit,详见图片摘要。report_url 是该报告的公开核验页。使用测试密钥时结果是固定的示例,其报告 URL 没有对应的页面。
检测大型 PDF
50 MB 以内的文档分三步上传:用 POST /files 和 JSON 请求体申请上传 URL,用 PUT 将文件内容发送到该 URL,并等待文件状态变为 uploaded。随后程序创建分析并每 5 秒轮询一次,因为文档可能需要排队,检测也可能需要一段时间。程序会输出进度、摘要和报告 URL,并把 PDF 报告保存在脚本所在目录。
将其保存为 check-pdf.mjs 或 check_pdf.py,然后运行 node check-pdf.mjs contract.pdf 或 python check_pdf.py contract.pdf。如需检测其他类型的文档,请将 content_type 改为支持的内容类型之一。如果文档已加密,请在分析请求中添加 options: { password }。
import { randomUUID } from "node:crypto";import { readFile, writeFile } from "node:fs/promises";import { basename } from "node:path"; const base = "https://api.fileverity.com/v1";const key = process.env.FILEVERITY_API_KEY;const path = process.argv[2];if (!key || !path) throw new Error("Usage: FILEVERITY_API_KEY=... node check-pdf.mjs contract.pdf"); const retryable = ["rate_limited", "idempotency_in_progress"];const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); async function request(url, init) { for (let attempt = 1; ; attempt++) { let response = null; let problem; try { response = await fetch(url, init); if (response.ok) { const remaining = response.headers.get("x-ratelimit-remaining-requests"); const reset = Number(response.headers.get("x-ratelimit-reset-requests")); if (remaining === "0") await sleep(reset); return response; } problem = (await response.json().catch(() => ({}))).error ?? {}; } catch (error) { problem = { code: "network_error", message: error.message }; } const status = response?.status ?? 0; const retry = status === 0 || status >= 500 || retryable.includes(problem.code); if (!retry || attempt === 5) { const { code = status, message = "Request failed", request_id: id = "unknown" } = problem; throw new Error(`${code}: ${message} (request ${id})`); } await sleep(Number(response?.headers.get("retry-after")) || 2 ** attempt); }} async function api(method, route, { headers, body } = {}) { const auth = { Authorization: `Bearer ${key}` }; const response = await request(base + route, { method, headers: { ...auth, ...headers }, body }); return response.json();} const bytes = await readFile(path);const file = await api("POST", "/files", { headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID() }, body: JSON.stringify({ filename: basename(path), content_type: "application/pdf", bytes: bytes.length, }),}); if (file.status === "pending") { const { url, method, headers } = file.upload; await request(url, { method, headers, body: bytes }); let ready = false; for (let attempt = 0; attempt < 15 && !ready; attempt++) { ready = (await api("GET", `/files/${file.id}`)).status === "uploaded"; if (!ready) await sleep(2); } if (!ready) throw new Error("The upload was not confirmed.");}console.log(`${file.id} uploaded`); let analysis = await api("POST", "/analyses", { headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID() }, body: JSON.stringify({ model: "fvdoc-3", file: file.id }),});const deadline = Date.now() + 15 * 60 * 1000;while (analysis.status === "processing") { if (Date.now() > deadline) { throw new Error(`Still processing. Fetch it later with GET /analyses/${analysis.id}`); } console.log(`${analysis.id} processing: ${analysis.progress?.stage ?? "starting"}`); await sleep(5); analysis = await api("GET", `/analyses/${analysis.id}`);} if (analysis.status !== "succeeded") { throw new Error(`The check ${analysis.status}: ${analysis.error?.message}`);}const { level, action, reason, pages, images, text, findings } = analysis.summary;console.log(`${analysis.id} ${analysis.status}`);console.log(`level ${level}, action ${action}, reason ${reason ?? "none"}`);console.log(`${pages} pages, ${images} photos examined, text: ${text ?? "not evaluated"}`);console.log(`findings: ${findings.join(", ") || "none"}`);console.log(`Report: ${analysis.report_url}`); if (analysis.livemode) { const pdf = await request(`${base}/analyses/${analysis.id}/report.pdf`, { headers: { Authorization: `Bearer ${key}` }, }); const name = `Fileverity-${analysis.report_id}.pdf`; await writeFile(name, Buffer.from(await pdf.arrayBuffer())); console.log(`Saved ${name}`);}import osimport sysimport timeimport uuid import requests BASE = "https://api.fileverity.com/v1"KEY = os.environ.get("FILEVERITY_API_KEY")if not KEY or len(sys.argv) < 2: sys.exit("Usage: FILEVERITY_API_KEY=... python check_pdf.py contract.pdf") RETRYABLE = ("rate_limited", "idempotency_in_progress") def request(method, url, **kwargs): for attempt in range(1, 6): try: response = requests.request(method, url, timeout=90, **kwargs) except requests.RequestException as error: response, problem = None, {"code": "network_error", "message": str(error)} else: if response.ok: if response.headers.get("x-ratelimit-remaining-requests") == "0": time.sleep(int(response.headers["x-ratelimit-reset-requests"])) return response try: problem = response.json()["error"] except (ValueError, KeyError, TypeError): problem = {} status = response.status_code if response is not None else 0 retry = status == 0 or status >= 500 or problem.get("code") in RETRYABLE if not retry or attempt == 5: code = problem.get("code", status) message = problem.get("message", "Request failed") request_id = problem.get("request_id", "unknown") raise RuntimeError(f"{code}: {message} (request {request_id})") wait = response.headers.get("retry-after") if response is not None else None time.sleep(int(wait or 2**attempt)) def api(method, route, headers=None, **kwargs): headers = {"Authorization": f"Bearer {KEY}", **(headers or {})} return request(method, BASE + route, headers=headers, **kwargs).json() with open(sys.argv[1], "rb") as source: data = source.read()file = api( "POST", "/files", headers={"Idempotency-Key": str(uuid.uuid4())}, json={ "filename": os.path.basename(sys.argv[1]), "content_type": "application/pdf", "bytes": len(data), },) if file["status"] == "pending": request("PUT", file["upload"]["url"], headers=file["upload"]["headers"], data=data) for _ in range(15): if api("GET", f"/files/{file['id']}")["status"] == "uploaded": break time.sleep(2) else: sys.exit("The upload was not confirmed.")print(f"{file['id']} uploaded") analysis = api( "POST", "/analyses", headers={"Idempotency-Key": str(uuid.uuid4())}, json={"model": "fvdoc-3", "file": file["id"]},)deadline = time.time() + 15 * 60while analysis["status"] == "processing": if time.time() > deadline: sys.exit(f"Still processing. Fetch it later with GET /analyses/{analysis['id']}") stage = (analysis["progress"] or {}).get("stage", "starting") print(f"{analysis['id']} processing: {stage}") time.sleep(5) analysis = api("GET", f"/analyses/{analysis['id']}") if analysis["status"] != "succeeded": problem = analysis["error"] or {} sys.exit(f"The check {analysis['status']}: {problem.get('message')}")summary = analysis["summary"]reason = summary["reason"] or "none"text = summary["text"] or "not evaluated"findings = ", ".join(summary["findings"]) or "none"print(f"{analysis['id']} {analysis['status']}")print(f"level {summary['level']}, action {summary['action']}, reason {reason}")print(f"{summary['pages']} pages, {summary['images']} photos examined, text: {text}")print(f"findings: {findings}")print(f"Report: {analysis['report_url']}") if analysis["livemode"]: url = f"{BASE}/analyses/{analysis['id']}/report.pdf" pdf = request("GET", url, headers={"Authorization": f"Bearer {KEY}"}) name = f"Fileverity-{analysis['report_id']}.pdf" with open(name, "wb") as target: target.write(pdf.content) print(f"Saved {name}")file_Pw3mY6zB8Hk2pQ4vN9sL1xR7 uploadedan_Zc4vB7nP1sK8tL3wR6yQm2Xa processing: queuedan_Zc4vB7nP1sK8tL3wR6yQm2Xa processing: analyzingan_Zc4vB7nP1sK8tL3wR6yQm2Xa processing: reportingan_Zc4vB7nP1sK8tL3wR6yQm2Xa succeededlevel L2, action accept, reason clean12 pages, 3 photos examined, text: humanfindings: noneReport: https://fileverity.com/verify/FV-3PXH-6WQ2-8NDCSaved Fileverity-FV-3PXH-6WQ2-8NDC.pdfPUT请求直接发送到存储服务,不需要Authorization请求头,upload.headers中的请求头按返回的内容原样发送。参见预签名上传。- 使用测试密钥时,文件直接返回
uploaded状态,无需上传任何内容,因此程序会跳过上传。分析会立即返回固定的示例结果,并且没有示例 PDF,所以程序只在使用 Live 密钥时保存报告。 - 检测完成后,请立即下载 PDF。文档的页面图片和文本摘录只保留 3 小时,之后下载的 PDF 将不再包含这些内容。参见 PDF 报告。
- 以
failed或canceled结束的检测不会扣费。程序会以该分析的错误信息报错并停止。
检测 AI 撰写的文本
文本检测只需一次同步调用。程序读取文本文件,将其发送到 POST /text/detections,然后输出判定结果、分数、AI 撰写文本的占比,以及每个 AI 撰写的段落及其偏移量。AI 撰写文本检测支持英语、法语、德语、西班牙语和中文(en、fr、de、es、zh)。语言会自动识别;也可以将语言代码作为第二个参数传入来指定。文本不得少于 100 个词,且不得超过 100,000 个字符。
将其保存为 detect-text.mjs 或 detect_text.py,然后运行 node detect-text.mjs essay.txt 或 python detect_text.py essay.txt fr。快速入门中提供了生成示例 essay.txt 的命令。使用测试密钥时,响应是针对人类撰写文本的固定结果,因此程序不会输出任何段落。
import { randomUUID } from "node:crypto";import { readFile } from "node:fs/promises"; const base = "https://api.fileverity.com/v1";const key = process.env.FILEVERITY_API_KEY;const [, , path, language] = process.argv;if (!key || !path) { throw new Error("Usage: FILEVERITY_API_KEY=... node detect-text.mjs essay.txt [en|fr|de|es|zh]");} const retryable = ["rate_limited", "idempotency_in_progress"];const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); async function request(url, init) { for (let attempt = 1; ; attempt++) { let response = null; let problem; try { response = await fetch(url, init); if (response.ok) { const remaining = response.headers.get("x-ratelimit-remaining-requests"); const reset = Number(response.headers.get("x-ratelimit-reset-requests")); if (remaining === "0") await sleep(reset); return response; } problem = (await response.json().catch(() => ({}))).error ?? {}; } catch (error) { problem = { code: "network_error", message: error.message }; } const status = response?.status ?? 0; const retry = status === 0 || status >= 500 || retryable.includes(problem.code); if (!retry || attempt === 5) { const { code = status, message = "Request failed", request_id: id = "unknown" } = problem; throw new Error(`${code}: ${message} (request ${id})`); } await sleep(Number(response?.headers.get("retry-after")) || 2 ** attempt); }} async function api(method, route, { headers, body } = {}) { const auth = { Authorization: `Bearer ${key}` }; const response = await request(base + route, { method, headers: { ...auth, ...headers }, body }); return response.json();} const text = await readFile(path, "utf8");const detection = await api("POST", "/text/detections", { headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID() }, body: JSON.stringify({ text, language }),}); const { verdict, score, ai_fraction: share, usage, segments } = detection;console.log(`verdict: ${verdict} (score ${score.toFixed(2)})`);console.log(`AI-written share: ${Math.round(share * 100)}% of ${usage.characters} characters`);console.log(`language: ${detection.language}, billed units: ${usage.billed_units}`);const points = Array.from(text);for (const { start, end, score: passage } of segments) { const excerpt = points.slice(start, Math.min(end, start + 80)).join(""); console.log(`${start}-${end} score ${passage.toFixed(2)}: ${excerpt}`);}import osimport sysimport timeimport uuid import requests BASE = "https://api.fileverity.com/v1"KEY = os.environ.get("FILEVERITY_API_KEY")if not KEY or len(sys.argv) < 2: sys.exit("Usage: FILEVERITY_API_KEY=... python detect_text.py essay.txt [en|fr|de|es|zh]")sys.stdout.reconfigure(encoding="utf-8") RETRYABLE = ("rate_limited", "idempotency_in_progress") def request(method, url, **kwargs): for attempt in range(1, 6): try: response = requests.request(method, url, timeout=90, **kwargs) except requests.RequestException as error: response, problem = None, {"code": "network_error", "message": str(error)} else: if response.ok: if response.headers.get("x-ratelimit-remaining-requests") == "0": time.sleep(int(response.headers["x-ratelimit-reset-requests"])) return response try: problem = response.json()["error"] except (ValueError, KeyError, TypeError): problem = {} status = response.status_code if response is not None else 0 retry = status == 0 or status >= 500 or problem.get("code") in RETRYABLE if not retry or attempt == 5: code = problem.get("code", status) message = problem.get("message", "Request failed") request_id = problem.get("request_id", "unknown") raise RuntimeError(f"{code}: {message} (request {request_id})") wait = response.headers.get("retry-after") if response is not None else None time.sleep(int(wait or 2**attempt)) def api(method, route, headers=None, **kwargs): headers = {"Authorization": f"Bearer {KEY}", **(headers or {})} return request(method, BASE + route, headers=headers, **kwargs).json() with open(sys.argv[1], encoding="utf-8") as source: text = source.read()language = sys.argv[2] if len(sys.argv) > 2 else Nonedetection = api( "POST", "/text/detections", headers={"Idempotency-Key": str(uuid.uuid4())}, json={"text": text, "language": language},) usage = detection["usage"]share = round(detection["ai_fraction"] * 100)print(f"verdict: {detection['verdict']} (score {detection['score']:.2f})")print(f"AI-written share: {share}% of {usage['characters']} characters")print(f"language: {detection['language']}, billed units: {usage['billed_units']}")for segment in detection["segments"]: start, end = segment["start"], segment["end"] excerpt = text[start : min(end, start + 80)] print(f"{start}-{end} score {segment['score']:.2f}: {excerpt}")verdict: mixed (score 0.97)AI-written share: 50% of 889 characterslanguage: en, billed units: 1267-495 score 0.97: In today's fast-paced logistics landscape, it is essential for businesses to pri590-809 score 0.93: Moving forward, it is crucial to implement robust quality assurance measures andstart 和 end 按 Unicode 码点计数,而不是字节,也不是 UTF-16 码元。Python 字符串本身就按码点索引;Node.js 程序会先用 Array.from 拆分文本。参见段落偏移量。
文本检测返回的是统计估计,不是作者身份的证明。请将其作为人工复核的参考依据之一,不要单独依据它作出影响个人的决定。
接收 Webhook 并校验签名
Webhook 会在检测完成时通知您的服务器,因此无需轮询。这个接收程序是一个最简 HTTP 服务器:Node.js 使用 http 模块,Python 使用 Flask。它会校验每次投递的签名,忽略已经处理过的投递,并输出事件。实现遵循 Webhooks 页面的说明。
- 让 3000 端口可通过公网 HTTPS 地址访问,例如通过隧道指向您的本机。端点 URL 必须使用 HTTPS 和默认端口 443,并指向公网地址。
- 在控制台中打开 Webhooks,添加一个 URL 为
https://your-host/webhooks/fileverity的端点,并选择其事件。复制以whsec_开头的签名密钥,它只显示一次。 - 将程序保存为
webhook-receiver.mjs或webhook_receiver.py,并带上密钥启动:FILEVERITY_WEBHOOK_SECRET=whsec_... node webhook-receiver.mjs或FILEVERITY_WEBHOOK_SECRET=whsec_... python webhook_receiver.py。 - 在该端点上选择发送测试。测试密钥不会发送事件,而发送测试会投递一个经过签名的示例事件。
import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http"; const secret = process.env.FILEVERITY_WEBHOOK_SECRET;if (!secret) throw new Error("Set FILEVERITY_WEBHOOK_SECRET to the endpoint's signing secret.");const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");const port = Number(process.env.PORT) || 3000;const seen = new Set(); function verify(headers, body) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) return false; if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return false; const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest(); return signatures.split(" ").some((entry) => { const [version, value = ""] = entry.split(","); const given = Buffer.from(value, "base64"); return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected); });} function handleEvent(event) { const { type, data } = event; if (data.test) { console.log(`Test event ${event.id}`); } else if (type === "analysis.completed") { const { level, action } = data.summary; console.log(`${data.id} succeeded: level ${level}, action ${action}, ${data.report_url}`); } else if (type === "analysis.failed") { console.log(`${data.id} ${data.status}: ${data.error.code}`); } else if (type === "balance.low") { const money = (cents) => `US$${(cents / 100).toFixed(2)}`; console.log(`Balance ${money(data.balance)} is below your ${money(data.threshold)} alert`); }} async function receive(req, res) { if (req.method !== "POST" || req.url !== "/webhooks/fileverity") return res.writeHead(404).end(); const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > 1_000_000) return res.writeHead(413).end(); chunks.push(chunk); } const body = Buffer.concat(chunks); if (!verify(req.headers, body)) return res.writeHead(400).end(); const event = JSON.parse(body.toString("utf8")); try { if (!seen.has(event.id)) { handleEvent(event); seen.add(event.id); } } catch (error) { console.error(`Could not handle ${event.id}`, error); return res.writeHead(500).end(); } res.writeHead(200).end();} createServer((req, res) => receive(req, res).catch(() => res.destroy())).listen(port, () => console.log(`Listening on port ${port}`));import base64import hashlibimport hmacimport jsonimport osimport threadingimport time from flask import Flask, request SECRET = os.environ.get("FILEVERITY_WEBHOOK_SECRET")if not SECRET: raise SystemExit("Set FILEVERITY_WEBHOOK_SECRET to the endpoint's signing secret.")KEY = base64.b64decode(SECRET.removeprefix("whsec_"))seen = set()lock = threading.Lock()app = Flask(__name__)app.config["MAX_CONTENT_LENGTH"] = 1_000_000 def verify(headers, body): message_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp", "") signatures = headers.get("webhook-signature", "") if not message_id or not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > 300: return False signed = f"{message_id}.{timestamp}.".encode() + body expected = hmac.new(KEY, signed, hashlib.sha256).digest() for entry in signatures.split(" "): version, _, value = entry.partition(",") try: given = base64.b64decode(value, validate=True) except ValueError: continue if version == "v1" and hmac.compare_digest(given, expected): return True return False def handle_event(event): data = event["data"] if data.get("test"): print(f"Test event {event['id']}") elif event["type"] == "analysis.completed": level, action = data["summary"]["level"], data["summary"]["action"] print(f"{data['id']} succeeded: level {level}, action {action}, {data['report_url']}") elif event["type"] == "analysis.failed": print(f"{data['id']} {data['status']}: {data['error']['code']}") elif event["type"] == "balance.low": balance, threshold = data["balance"] / 100, data["threshold"] / 100 print(f"Balance US${balance:.2f} is below your US${threshold:.2f} alert") @app.post("/webhooks/fileverity")def receive(): body = request.get_data() if not verify(request.headers, body): return "", 400 event = json.loads(body) with lock: try: if event["id"] not in seen: handle_event(event) seen.add(event["id"]) except Exception: app.logger.exception("Could not handle %s", event["id"]) return "", 500 return "", 200 if __name__ == "__main__": port = int(os.environ.get("PORT", "3000")) print(f"Listening on port {port}") app.run(port=port)Listening on port 3000Test event evt_Qm4vB7nP1sK8tL3wR6yZ2cXaan_8Hk2pQ4vN9sL1xR7cW3mY6zB succeeded: level L2, action accept, https://fileverity.com/verify/FV-7K2M-9QXD-4T8R- 原始请求体:签名覆盖实际发送的原始字节。程序以字节形式读取请求体,使用经 Base64 解码的签名密钥作为 HMAC 密钥,对
webhook-id、一个英文句点、webhook-timestamp、一个英文句点和这些字节计算 HMAC-SHA256,并且只在签名有效后才解析 JSON。 - 恒定时间比较:Node.js 中的
timingSafeEqual和 Python 中的hmac.compare_digest会将结果与webhook-signature请求头中的每个v1签名进行比较。 - 时间戳容差:
webhook-timestamp与系统时钟相差超过五分钟,或者不是数字,都会被拒绝。 - 快速响应:有效的投递在事件处理完成后立即返回
200;签名无效则返回400。如果处理失败,程序返回500,Fileverity 稍后会使用相同的事件 ID 重试。参见投递与重试。 - 重复投递:投递至少发生一次,重试沿用同一个事件 ID。程序会记住已处理的事件 ID,对重复事件直接返回
200,不会重复处理。在生产环境中,请将内存中的集合替换为数据库中事件 ID 的唯一约束,并把实际工作放入队列,以保持响应迅速。 - 事件数据:
analysis.completed和analysis.failed携带不含result的分析对象;需要完整报告时,请调用GET /analyses/{id}。balance.low中的balance和threshold以美分为单位。
后续步骤
- 参照受理流程中的文档检测,运行包含人工复核的完整受理流程。
- 在 API 参考中查阅任意字段,或将 OpenAPI 文档导入您自己的工具。
- 正式上线时,使用 Live 密钥,并设置每月支出上限和
balance.lowWebhook。参见价格与计费。 - 需要帮助?联系技术支持时,请提供
x-request-id响应头的值。