探测 API 文档
从全国多省份、多运营商线路发起探测,每条线路执行一次计 1 次。价格见 API 套餐。
1. 接入步骤
- ,在用户中心「账户」页新建 API Key 并选择权限,Secret 只在新建时显示一次,请妥善保存。一个账号最多 10 把,可按用途分开(如生产、测试、只读看板)。
- 首次调用自动获得 1000 次试用(30 天),可直接联调。
- 正式使用前在 API 套餐 页申请购买。
2. 鉴权与签名
接口地址前缀 https://www.tance.cc/api-proxy。每个请求携带三个请求头:
Authorization: Bearer <API Key>X-Timestamp:当前 Unix 秒级时间戳,与服务器偏差不超过 5 分钟X-Signature:hex(HMAC-SHA256(Secret, METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + BODY))
PATH 为以 /api/v1/ 开头的路径(不含 /api-proxy 前缀和查询参数);BODY 为发送的原始请求体,GET 请求为空串。同一签名只能使用一次,重试时必须用新的时间戳重新签名。若为 Key 设置了来源 IP 白名单(账户页「来源 IP」一栏,最多 20 条 IP 或 CIDR,修改立即生效),只接受白名单内的来源,其余返回 401。
权限:每把 Key 带一组权限,缺少所需权限返回 403 INSUFFICIENT_SCOPE。
tests:read(读取结果):GET /api/v1/tests/{task_id}tests:write(创建任务):POST /api/v1/tests,会派发探测、可能扣费monitors:read(读取监控):监控列表、24 小时汇总、故障事件、可选线路组monitors:write(管理监控):创建、修改、启动、停止、删除持续监控,运行中的监控占用观测槽位
账户页新建时默认勾选前 3 项(「管理监控」需手动勾选);通过接口建 Key 不传 scopes 时为当前全部权限。以后新增的权限不会自动加给已有的 Key(持续监控接口上线前建的 Key 没有 monitors:*,要用请新建)。只拉结果的看板只给「读取结果」,泄露了也不能替你花钱。权限功能上线前签发的存量 Key 按除「管理监控」以外的全部权限对待(包括以后新增的),不能停止或删除监控。
不停机轮换:新建一把 → 调用方切到新 Key → 在账户页看旧 Key 的「最近使用」(最多延迟 30 秒)确认已无流量 → 吊销旧 Key。吊销立即生效,同账号的其他 Key 不受影响。
3. 创建探测任务
POST /api/v1/tests,请求体:
type:ping/tcping/http/dns/route/ssltarget:域名、IP 或 URL(http 类型)port:tcping / ssl 端口(可选)ip_version:ipv4(默认)或ipv6lines:按运营商筛选,如["电信","联通"](可选,不填为全部线路)params:高级参数,如 http 的method、headers、follow_redirects(可选)callback_endpoint_id:任务结束后把结果推送到这个回调地址(可选,见第 6 节;不传或传 0 不推送)
成功返回 202,reserved_units 为本次预占的次数(= 派发的线路数)。任务结束后按实际返回结果的线路数扣费,其余退回。可携带 Idempotency-Key 请求头(≤64 字节,建议用 UUID;超长返回 400 VALIDATION,不会被悄悄忽略):同一 Key 同一请求体重复提交返回原任务(200,status: "replayed"),不会重复扣费。
KEY='你的 API Key'
SECRET='你的 API Secret'
BODY='{"type":"ping","target":"example.com"}'
TS=$(date +%s)
SIG=$(printf 'POST\n/api/v1/tests\n%s\n%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST 'https://www.tance.cc/api-proxy/api/v1/tests' \
-H "Authorization: Bearer $KEY" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
-H 'Content-Type: application/json' -d "$BODY"4. 查询结果
GET /api/v1/tests/{task_id}(同样需要签名,BODY 为空)。各类型的执行窗口:ping、tcping、dns、ssl 约 15 秒;http 为超时(timeout_ms,默认 10 秒)加 5 秒,与 15 秒取大,最长 35 秒;路由逐跳探测,最长 60 秒(途中多跳不应答时会在窗口内停下,结果标「未完成」)。status 为 completed 时结果齐全。建议最多轮询 90 秒:90 秒时仍为 pending / dispatched / running,说明有线路未回报,以当时返回的结果为准即可,不要无限等待。轮询间隔不短于 2 秒:轮询与创建共用每账号每分钟的请求数(默认 60,见下方 429),同时等多个任务时按任务数拉长间隔(例如同时 5 个任务,每个约 5 秒查一次)。服务端约 3 分钟内一定收敛到终态:partial(部分线路回报)、timeout(一条都没回)或 failed(没有可用线路);按实际回报的线路数计费。只能查询本账号创建的任务。检测结果保留 30 天:超过后再查询,任务还在时返回空的 results 和 "results_expired": true,需要长期保存请自行落库。
{
"status": "completed",
"expected_nodes": 86, "reported_nodes": 86,
"results": [
{
"node_uuid": "…", "status": "success", "is_final": true,
"node": { "country": "中国", "province": "广东省", "city": "深圳", "isp": "电信" },
"result": { "rtt": { "min": 11.8, "avg": 12.4, "max": 13.1 }, "resolved_ip": "93.184.215.14" }
}
]
}5. 持续监控
与网站「持续监控」是同一批数据、同一套规则(监控数上限、观测槽位、目标限制):在这里建的监控在网站上可见,反之亦然。告警渠道与告警规则目前在网站上配置。各接口可能返回的错误码见第 7 节。
| 接口 | 权限 | 说明 |
|---|---|---|
GET /api/v1/line-groups | monitors:read | 可选的线路组;key 即创建监控时 line_groups 的取值,free 为剩余槽位 |
GET /api/v1/monitors | monitors:read | 本账号的全部监控 |
GET /api/v1/monitors/{id} | monitors:read | 监控配置 + 最近 24 小时汇总(可用率、平均延迟、最大 p95、丢包) |
GET /api/v1/monitors/{id}/incidents | monitors:read | 故障事件,status=open(默认)/ resolved / all,按 before_id 翻页 |
POST /api/v1/monitors | monitors:write | 创建并立即运行;槽位不足、线路组不可售等情况整单拒绝 |
PATCH /api/v1/monitors/{id} | monitors:write | 只改给出的字段;type 与 ip_version 不能改;运行中的监控先按新配置预留槽位,不够则不改 |
POST /api/v1/monitors/{id}/start | monitors:write | 启动(预留槽位);配置无效的监控需先改正 |
POST /api/v1/monitors/{id}/stop | monitors:write | 停止探测并释放槽位(只想不收告警请在网站上用「静默」) |
DELETE /api/v1/monitors/{id} | monitors:write | 删除并释放槽位,返回 204 |
创建示例:{"type":"http","target":"https://example.com/","interval_seconds":60,"line_groups":["中国|*|*|ipv4"]}。interval_seconds 为 30–86400 秒(创建时不填为 60),越界直接报错,不会被悄悄调整;请求体里不认识的字段同样报错。开启观测槽位时每个线路组占 ceil(300 / 间隔) 个槽位,例如 1 分钟间隔占 5 个。
summary_24h 只统计最近 24 小时内的小时桶(按时间过滤,停更的监控不会把几天前的数据显示成今天的可用率),窗口内没有数据时各指标为 null;availability 与 packet_loss 都是百分数(0–100);汇总约有 2 分钟延迟。
6. 任务回调
不想轮询时,可以让平台在任务结束后把结果推送给你:
- 在控制台 页添加接收地址(公网 http/https,最多 5 个;内网、本机地址会被拒绝)。添加时显示一次 Secret(
whsec_…),请当场保存。 - 创建任务时带上
"callback_endpoint_id": 地址 ID。地址不存在、已停用或不属于本账号时返回400 INVALID_CALLBACK_ENDPOINT,不建任务、不扣费。 - 任务进入终态(
completed/partial/timeout/failed)后,平台向该地址POST一条test.finished事件;控制台的「测试」按钮发送test.ping(task为 null)。
请求头:X-Tance-Delivery(投递 ID,UUID;重试和重放都不变,按它去重)、X-Tance-Event、X-Tance-Timestamp(本次发送的 Unix 秒,重试时会变)、X-Tance-Signature、User-Agent: tance-callback/1。
请求体:results 与 GET /api/v1/tests/{task_id} 的返回相同;整体超过 256KB 时不带 results、results_truncated 为 true,请按 results_url 自行拉取。
{
"schema_version": "2026-10",
"event_id": "8f0c…(与 X-Tance-Delivery 相同,重试与重放不变)",
"event_type": "test.finished",
"created_at": "2026-10-20T18:55:34+08:00",
"user_id": 1024,
"task": {
"id": 52866, "uuid": "task_…", "type": "tcping", "target": "example.com",
"status": "completed", "expected_nodes": 32, "reported_nodes": 32,
"completed_at": "2026-10-20T18:55:34+08:00"
},
"results": { "…": "与 GET /api/v1/tests/{task_id} 的返回相同" },
"results_truncated": false,
"results_url": "/api/v1/tests/52866"
}送达与重试:返回任意 2xx 算送达,请先返回再慢慢处理(整次请求 10 秒超时,连接超时 5 秒,只读响应前 4KB)。408、425、429、5xx、超时和网络错误会重试,共 8 次(约 1 分钟、5 分钟、30 分钟、2 小时、6 小时、24 小时、48 小时后,各 ±20%;对方返回 Retry-After 时不早于它,最多按 1 小时算);3xx(不跟随重定向)和其他 4xx 不重试。仍失败的投递记为失败并发站内信(同一地址 24 小时内提醒一次),修好接收端后可在控制台重放。至少送达一次,不保证顺序。投递记录保留 30 天。
验签:X-Tance-Signature = v1= + hex(HMAC-SHA256(Secret, X-Tance-Delivery + "." + X-Tance-Timestamp + "." + 原始请求体))。用原始字节计算,不要先解析 JSON 再序列化;用常量时间比较;时间戳与当前相差超过 5 分钟的拒收。轮换 Secret 立即生效,旧 Secret 签的请求不再出现,请在轮换后马上更新接收端。
自测向量:Secret whsec_testvector、Delivery 00000000-0000-4000-8000-000000000001、Timestamp 1700000000、请求体 {"a":1},签名应为 v1=c67bb1c9563478a4c1ec123f250407711e7d1d090742523ec499d1a2f5e0d0cd(用下面的 sign 计算;这个时间戳早已超出 5 分钟,verify 会拒收)。
Python 3:
import hashlib, hmac, time
def sign(secret: str, delivery: str, ts: str, body: bytes) -> str:
mac = hmac.new(secret.encode(), f"{delivery}.{ts}.".encode() + body, hashlib.sha256)
return "v1=" + mac.hexdigest()
def verify(secret: str, headers, body: bytes, tolerance: int = 300) -> bool:
"""headers: 请求头(大小写按你的框架处理);body: 原始请求体字节,不要先解析再序列化。"""
try:
ts = headers["X-Tance-Timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
want = sign(secret, headers["X-Tance-Delivery"], ts, body)
except (KeyError, ValueError): # 缺请求头或时间戳不是数字:拒收,不抛异常
return False
return hmac.compare_digest(want, headers.get("X-Tance-Signature", ""))Node.js 18+:
import crypto from 'node:crypto';
export function sign(secret, delivery, ts, rawBody) {
return 'v1=' + crypto.createHmac('sha256', secret).update(`${delivery}.${ts}.`).update(rawBody).digest('hex');
}
// headers: Node 的小写请求头;rawBody: 原始请求体(Buffer 或字符串),例如 express.raw({ type: 'application/json' })
export function verify(secret, headers, rawBody, toleranceSec = 300) {
const ts = headers['x-tance-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
const want = Buffer.from(sign(secret, headers['x-tance-delivery'], ts, rawBody));
const got = Buffer.from(headers['x-tance-signature'] ?? '');
return got.length === want.length && crypto.timingSafeEqual(got, want);
}Go:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
)
func sign(secret, delivery, ts string, body []byte) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(delivery + "." + ts + "."))
m.Write(body)
return "v1=" + hex.EncodeToString(m.Sum(nil))
}
// body 是原始请求体(io.ReadAll(r.Body) 的结果)。
func verify(secret string, r *http.Request, body []byte) bool {
ts := r.Header.Get("X-Tance-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return false
}
want := sign(secret, r.Header.Get("X-Tance-Delivery"), ts, body)
return hmac.Equal([]byte(want), []byte(r.Header.Get("X-Tance-Signature")))
}7. 错误码
| HTTP | code | 说明 |
|---|---|---|
| 400 | VALIDATION / UNSUPPORTED_TYPE / INVALID_TARGET | 参数错误(含探测参数超出范围——次数 1–10、超时 100–30000 毫秒、跳数 1–64、探测数 1–5、重定向 1–20(0 或不填为默认 10 次)、端口 1–65535,Idempotency-Key 超过 64 字节、单个批量里同一主机或同一域名的目标数超过其每分钟额度)、类型不支持或目标被拒绝(内网地址、黑名单、写法不合规——ping、tcping、dns、路由、证书只接受主机名或 IP,不接受网址,也不带端口(端口用 port 字段);网址只接受小写 http:// 或 https:// 开头,主机部分不能有 % 或反斜杠,端口须为数字,只有 http 类型可在主机前带用户名密码(按 Basic Auth 发送);查询串里带 @ 时在 ? 前加 / 即可;批量检测逐个检查列表里的每个目标),不扣费 |
| 401 | UNAUTHENTICATED | Key 不存在或已吊销、签名错误或来源 IP 不在白名单(为不泄露 key 是否存在,三者提示相同,可联系客服按请求时间查原因);时间戳偏差超过 5 分钟会单独提示检查时钟 |
| 401 | REPLAYED | 同一签名只能使用一次;重试请用新的 X-Timestamp 重新签名 |
| 402 | PACKAGE_QUOTA_EXHAUSTED | 额度不足,整单拒绝,不会自动扣费 |
| 402 | PACKAGE_EXPIRED | 套餐已到期,续费后恢复 |
| 403 | INSUFFICIENT_SCOPE | Key 没有该接口需要的权限(见第 2 节),在扣费和派发之前拒绝 |
| 403 | FORBIDDEN | Key 没有归属账号 |
| 400 | INVALID_CALLBACK_ENDPOINT | callback_endpoint_id 无效:回调地址不存在、不属于本账号、已停用,或平台未开通任务回调;不扣费、不建任务。不传或传 0 表示不回调 |
| 400 | LINE_GROUPS_REQUIRED / LINE_GROUP_INVALID / GROUP_NOT_SELLABLE | 持续监控:没选线路组、线路组写法不对(数量过多或与监控的 IP 版本不一致)、或所选线路组当前不可售;可选的组用 GET /api/v1/line-groups 查 |
| 402 | SLOT_QUOTA_EXCEEDED / FREE_TIER_FULL | 持续监控:观测槽位不够(免费额度或套餐已用完),或该线路组给免费用户的名额已满(开通观测套餐即不受此限);整单拒绝,不会留下半建好的监控 |
| 402 | EMAIL_NOT_VERIFIED | 持续监控:免费观测份额需先验证账号邮箱(验证前不能新增,已在运行的监控不受影响);开通观测套餐即不受此限 |
| 409 | GROUP_CAPACITY_FULL | 持续监控:所选线路组容量已满,换一个线路组 |
| 400 | PROJECT_INVALID | 持续监控:所属项目不属于本账号。Open API 不设置项目、修改时沿用原项目,正常不会出现 |
| 404 | NOT_FOUND | 任务或监控不存在,或不属于当前账号 |
| 409 | IDEMPOTENCY_CONFLICT | 同一 Idempotency-Key 对应了不同的请求内容 |
| 409 | IDEMPOTENCY_IN_PROGRESS | 同一 Idempotency-Key 的首个请求仍在创建中,稍后重试 |
| 429 | RATE_LIMITED | 超出限额,按 Retry-After 等待后重新签名再试,不扣费、不派发。三种情况:① 账号请求过于频繁(默认每账号每分钟 60 个请求,查询与创建合计,同一账号的所有 Key 共用);② 在途任务已满(默认最多 10 个未结束的任务,任务结束后恢复;此时带同一 Idempotency-Key 的重试也会 429,稍后重试拿到原任务);③ 对同一目标创建过于频繁(默认同一账号对同一主机每分钟最多 6 个、对同一域名(含所有子域名)每分钟最多 15 个,同一域名跨所有账号每分钟最多 60 个,保护被测目标;IP 目标按网段计入域名额度(IPv4 同一 /24、IPv6 同一 /64);批量检测按列表里的每个目标分别计数;被拒的请求在拒绝前已占用的额度不退,Retry-After 已按补足整单所需计算,等待期间不要再发同一域名的请求)。数值可能按套餐调整 |
| 429 | QUOTA_EXCEEDED | 平台未开启套餐计费时,超过账号的每日任务数上限(开启计费后额度不足返回 402);创建持续监控时为监控数达到账号上限(403) |
| 500 | INTERNAL | 服务端错误,可稍后重试;创建请求请带 Idempotency-Key 以免重复扣费 |
| 503 | CAPACITY_UNAVAILABLE | 当前没有符合条件的可用线路(例如线路筛选过窄),不扣费 |
| 503 | TEMPORARILY_UNAVAILABLE | 防重放检查暂时不可用,创建请求被拒绝(未派发、不扣费);按 Retry-After 重新签名后重试 |
错误响应格式:{"error":{"code":"…","message":"…"}}
8. 示例代码
Python 3(仅标准库):
import hashlib, hmac, json, time, urllib.request
BASE = "https://www.tance.cc/api-proxy"
KEY, SECRET = "你的 API Key", "你的 API Secret"
def call(method, path, body=None, idempotency_key=None):
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
ts = str(int(time.time()))
sig = hmac.new(SECRET.encode(), f"{method}\n{path}\n{ts}\n".encode() + raw, hashlib.sha256).hexdigest()
headers = {"Authorization": f"Bearer {KEY}", "X-Timestamp": ts, "X-Signature": sig,
"Content-Type": "application/json"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(BASE + path, data=raw or None, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
status, task = call("POST", "/api/v1/tests", {"type": "http", "target": "https://example.com"})
print(status, task) # 202 {"task_id": ..., "reserved_units": ...}
time.sleep(15)
print(call("GET", f"/api/v1/tests/{task['task_id']}"))Node.js 18+:
import crypto from 'node:crypto';
const BASE = 'https://www.tance.cc/api-proxy';
const KEY = '你的 API Key', SECRET = '你的 API Secret';
async function call(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body);
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', SECRET).update(`${method}\n${path}\n${ts}\n${raw}`).digest('hex');
const res = await fetch(BASE + path, {
method, body: raw || undefined,
headers: { Authorization: `Bearer ${KEY}`, 'X-Timestamp': ts, 'X-Signature': sig, 'Content-Type': 'application/json' },
});
return [res.status, await res.json()];
}
const [status, task] = await call('POST', '/api/v1/tests', { type: 'tcping', target: 'example.com', port: 443 });
console.log(status, task);
豫公网安备41160202000677