logo

探测 API 文档

从全国多省份、多运营商线路发起探测,每条线路执行一次计 1 次。价格见 API 套餐。

1. 接入步骤

  1. ,在用户中心「账户」页新建 API Key 并选择权限,Secret 只在新建时显示一次,请妥善保存。一个账号最多 10 把,可按用途分开(如生产、测试、只读看板)。
  2. 首次调用自动获得 1000 次试用(30 天),可直接联调。
  3. 正式使用前在 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 / ssl
  • target:域名、IP 或 URL(http 类型)
  • port:tcping / ssl 端口(可选)
  • ip_version:ipv4(默认)或 ipv6
  • lines:按运营商筛选,如 ["电信","联通"](可选,不填为全部线路)
  • 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-groupsmonitors:read可选的线路组;key 即创建监控时 line_groups 的取值,free 为剩余槽位
GET /api/v1/monitorsmonitors:read本账号的全部监控
GET /api/v1/monitors/{id}monitors:read监控配置 + 最近 24 小时汇总(可用率、平均延迟、最大 p95、丢包)
GET /api/v1/monitors/{id}/incidentsmonitors:read故障事件,status=open(默认)/ resolved / all,按 before_id 翻页
POST /api/v1/monitorsmonitors:write创建并立即运行;槽位不足、线路组不可售等情况整单拒绝
PATCH /api/v1/monitors/{id}monitors:write只改给出的字段;type 与 ip_version 不能改;运行中的监控先按新配置预留槽位,不够则不改
POST /api/v1/monitors/{id}/startmonitors:write启动(预留槽位);配置无效的监控需先改正
POST /api/v1/monitors/{id}/stopmonitors: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. 任务回调

不想轮询时,可以让平台在任务结束后把结果推送给你:

  1. 在控制台 页添加接收地址(公网 http/https,最多 5 个;内网、本机地址会被拒绝)。添加时显示一次 Secret(whsec_…),请当场保存。
  2. 创建任务时带上 "callback_endpoint_id": 地址 ID。地址不存在、已停用或不属于本账号时返回 400 INVALID_CALLBACK_ENDPOINT,不建任务、不扣费。
  3. 任务进入终态(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. 错误码

HTTPcode说明
400VALIDATION / 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 发送);查询串里带 @ 时在 ? 前加 / 即可;批量检测逐个检查列表里的每个目标),不扣费
401UNAUTHENTICATEDKey 不存在或已吊销、签名错误或来源 IP 不在白名单(为不泄露 key 是否存在,三者提示相同,可联系客服按请求时间查原因);时间戳偏差超过 5 分钟会单独提示检查时钟
401REPLAYED同一签名只能使用一次;重试请用新的 X-Timestamp 重新签名
402PACKAGE_QUOTA_EXHAUSTED额度不足,整单拒绝,不会自动扣费
402PACKAGE_EXPIRED套餐已到期,续费后恢复
403INSUFFICIENT_SCOPEKey 没有该接口需要的权限(见第 2 节),在扣费和派发之前拒绝
403FORBIDDENKey 没有归属账号
400INVALID_CALLBACK_ENDPOINTcallback_endpoint_id 无效:回调地址不存在、不属于本账号、已停用,或平台未开通任务回调;不扣费、不建任务。不传或传 0 表示不回调
400LINE_GROUPS_REQUIRED / LINE_GROUP_INVALID / GROUP_NOT_SELLABLE持续监控:没选线路组、线路组写法不对(数量过多或与监控的 IP 版本不一致)、或所选线路组当前不可售;可选的组用 GET /api/v1/line-groups 查
402SLOT_QUOTA_EXCEEDED / FREE_TIER_FULL持续监控:观测槽位不够(免费额度或套餐已用完),或该线路组给免费用户的名额已满(开通观测套餐即不受此限);整单拒绝,不会留下半建好的监控
402EMAIL_NOT_VERIFIED持续监控:免费观测份额需先验证账号邮箱(验证前不能新增,已在运行的监控不受影响);开通观测套餐即不受此限
409GROUP_CAPACITY_FULL持续监控:所选线路组容量已满,换一个线路组
400PROJECT_INVALID持续监控:所属项目不属于本账号。Open API 不设置项目、修改时沿用原项目,正常不会出现
404NOT_FOUND任务或监控不存在,或不属于当前账号
409IDEMPOTENCY_CONFLICT同一 Idempotency-Key 对应了不同的请求内容
409IDEMPOTENCY_IN_PROGRESS同一 Idempotency-Key 的首个请求仍在创建中,稍后重试
429RATE_LIMITED超出限额,按 Retry-After 等待后重新签名再试,不扣费、不派发。三种情况:① 账号请求过于频繁(默认每账号每分钟 60 个请求,查询与创建合计,同一账号的所有 Key 共用);② 在途任务已满(默认最多 10 个未结束的任务,任务结束后恢复;此时带同一 Idempotency-Key 的重试也会 429,稍后重试拿到原任务);③ 对同一目标创建过于频繁(默认同一账号对同一主机每分钟最多 6 个、对同一域名(含所有子域名)每分钟最多 15 个,同一域名跨所有账号每分钟最多 60 个,保护被测目标;IP 目标按网段计入域名额度(IPv4 同一 /24、IPv6 同一 /64);批量检测按列表里的每个目标分别计数;被拒的请求在拒绝前已占用的额度不退,Retry-After 已按补足整单所需计算,等待期间不要再发同一域名的请求)。数值可能按套餐调整
429QUOTA_EXCEEDED平台未开启套餐计费时,超过账号的每日任务数上限(开启计费后额度不足返回 402);创建持续监控时为监控数达到账号上限(403)
500INTERNAL服务端错误,可稍后重试;创建请求请带 Idempotency-Key 以免重复扣费
503CAPACITY_UNAVAILABLE当前没有符合条件的可用线路(例如线路筛选过窄),不扣费
503TEMPORARILY_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);
投诉建议