跳到主要内容

鉴权与签名

服务地址​

用途正式环境测试环境
开发者控制台(页面)https://imo.lanchr.comhttps://im-open-test.lanchr.com
控制台 APIhttps://imo.lanchr.com/api/open/console/v1https://im-open-test.lanchr.com/api/open/console/v1
开放 API(客服 V1 / 机器人 V2 / 小程序 V2)https://im.lanchr.comhttps://im-test.lanchr.com

正式环境中控制台与开放 API 是两个不同的域名(imo.lanchr.com / im.lanchr.com),请勿混用。测试环境的开放 API 也可通过 https://im-open-test.lanchr.com 访问,与 https://im-test.lanchr.com 指向同一后端、行为一致(详见各 API 参考页的 Servers 一节)。

控制台 API​

控制台接口基地址为 /api/open/console/v1,供开发者控制台使用:

接口方法请求响应要点
auth/loginPOST{"phone":"<手机号>","password":"<密码>","channelId":1}返回 accessToken、expiresAt、developerId,并下发刷新 Cookie
auth/qr/generatePOST无返回 qrToken、qrImage(data:image/png;base64,...)
auth/qr/statusGETquery qrToken返回 status(如 pending)与 expireIn(单位:秒)
auth/refreshPOST依赖刷新 Cookie刷新 accessToken
auth/logoutPOST依赖刷新 Cookie注销当前会话
  • 受保护接口需携带 Authorization: Bearer <accessToken>;与租户相关的接口还需 X-Open-Tenant-Id,缺失时返回 tenant context required。
  • Access Token 有效期 7200 秒(2 小时);过期后调用 auth/refresh 刷新。
  • 刷新凭证保存在名为 open_refresh_token 的 HttpOnly、Secure、SameSite=Strict Cookie 中,有效期 30 天。

控制台 Access Token 应只保存在页面内存中,第三方业务服务不要复用控制台 Token。

开放 API 签名 V2​

机器人 V2、小程序运行时 API 以及客服 V1 API 都使用 App 凭证签名,采用同一套签名方案(签名 V2)——客服 V1 并不存在独立的「V1 签名规则」。每次请求携带:

X-Open-App-Id
X-Open-Credential-Id
X-Open-Timestamp
X-Open-Nonce
X-Open-Signature
Content-Type

⚠️ 必须显式设置 Content-Type。 除 GET 与 multipart 上传外,所有带 JSON 请求体的接口(POST/PUT)都必须发送请求头 Content-Type: application/json。

用 curl 发送 JSON 时,若只写 -d '...'/--data-binary '...',curl 会默认使用 application/x-www-form-urlencoded。此时请求虽然签名正确,但平台无法解析请求体,会返回 HTTP 415 + code 415001(Content-Type 不受支持)。拿到 415 请先查请求头,不要怀疑签名,也不要按服务端故障处理。

⚠️ message 里给出「收到的类型」后跟的那份「支持」清单不可信——它会把刚刚判定为不支持的那个类型原样列进去,例如「不支持的 Content-Type: application/x-www-form-urlencoded;charset=UTF-8,支持: [……, application/x-www-form-urlencoded, ……, */*]」。

这份清单是服务端已注册消息转换器的全局能力(含 */* 兜底),不是本接口实际接受的类型。不要用它判断该往哪个方向改,直接按接口要求改成 application/json 即可;也不要在代码里解析它。完整报文样例见错误与限制 → HTTP 状态。

正确写法:

curl -X POST 'https://im.lanchr.com/api/open/v2/bot/messages' \
-H 'Content-Type: application/json' \
-H 'X-Open-App-Id: <app-id>' \
-H 'X-Open-Credential-Id: <credential-id>' \
-H 'X-Open-Timestamp: <timestamp-ms>' \
-H 'X-Open-Nonce: <unique-nonce>' \
-H 'X-Open-Signature: <hmac-sha256>' \
--data-binary '{"receiveIdType":"user_id", ...}'

--data-binary 与 Content-Type 缺一不可:前者保证请求体按原始字节计算签名,后者保证平台能解析为 JSON。

multipart 上传(POST /api/open/v2/bot/files)的签名​

签名第 8 行是实际发出的整个 multipart 请求体的 SHA-256,它包含:每一段的分隔符行 --<boundary>、 每个分片的 Content-Disposition / Content-Type 头、分片之间与结尾的 \r\n、以及收尾的 --<boundary>--\r\n。因此必须先把请求体拼成确定的字节,再算哈希。

🚫 curl -F 'file=@a.png' 直接发一定失败。 boundary 由 curl 随机生成,你在签名时无法预知 那串字节,服务端算出的哈希与你签的对不上,返回 401 + 400001(signature verification failed)。 这不是凭证或权限问题,不要按鉴权故障排查。

正确做法是自己按下面的字节布局拼出请求体,对拼好的字节算哈希并签名,最后用 --data-binary 原样发出:

--<boundary>\r\n
Content-Disposition: form-data; name="file"; filename="a.png"\r\n
Content-Type: image/png\r\n
\r\n
<文件原始字节>
\r\n--<boundary>--\r\n

请求头写 Content-Type: multipart/form-data; boundary=<boundary>,这里的 <boundary> 是你自定义的 分隔符本身;而请求体每一段的起止行要在这个值前面再加两个 -。以分隔符 ----limBoundary7f3a91c2 为例,两个地方分别长这样:

请求头(值就是分隔符本身,共 4 个前导 -):

Content-Type: multipart/form-data; boundary=----limBoundary7f3a91c2

请求体(每段前面再加 2 个 -,即 6 个前导 -;末段末尾还要跟 -- 收尾):

------limBoundary7f3a91c2
Content-Disposition: form-data; name="file"; filename="a.png"
Content-Type: image/png

hello-multipart
------limBoundary7f3a91c2--

上面为便于阅读按行展示。拼接时每一处换行都必须是 \r\n 而不是 \n——这既是 multipart 规范的要求, 也直接决定第 8 行的哈希:用 \n 拼出的字节与正确写法哈希不同,签名自然对不上。

注意签名规范串里不包含 Content-Type,所以 boundary 取值本身不影响签名;真正决定签名的是 「你拼出的字节」与「你实际发出的字节」是否一致。见下方 multipart 签名测试向量。

规范请求串由八行组成,最后一行不追加换行:

UPPERCASE_HTTP_METHOD
REQUEST_PATH
CANONICAL_QUERY
APP_ID
CREDENTIAL_ID
TIMESTAMP_MILLISECONDS
NONCE
LOWERCASE_SHA256_HEX_OF_RAW_BODY

规范串中的 APP_ID 为控制台的应用 ID,CREDENTIAL_ID 即创建应用或轮换凭证时展示一次的 credentialId(cred_ 开头)。控制台「凭证」页不展示当前 credentialId,错过弹窗后只能通过「轮换凭证」重新获取一对新凭证。使用 App Secret 对完整规范请求串执行 HMAC-SHA256,签名输出为小写十六进制。请求体必须使用实际发送的 UTF-8 原始字节计算,不能在签名后重新格式化 JSON。

CANONICAL_QUERY 规范化​

第 3 行 CANONICAL_QUERY 不是原始 URL 查询串,必须按下述规则归一化后再参与签名,否则多参数接口会随机返回 401:

  1. 取原始查询串按 & 拆分,保留重复参数与空值;
  2. 对参数名与参数值做 UTF-8 百分号解码;
  3. 按参数名 ASCII 升序排列,同名参数再按参数值 ASCII 升序;
  4. 重新按 RFC 3986 做百分号编码(保留 -_.~,空格编码为 %20,不使用 +);
  5. 以 key=value 形式用 & 连接;没有查询参数时该行为空字符串。

示例(同一请求,仅参数顺序不同,规范化结果一致):

原始查询串CANONICAL_QUERY
visitorId=abc&page=1&size=20page=1&size=20&visitorId=abc
size=20&page=1&visitorId=abcpage=1&size=20&visitorId=abc
visitorId=verify visitor with spacevisitorId=verify%20visitor%20with%20space
(无参数)空字符串
const bodyHash = sha256(rawBodyBytes).toLowerCaseHex()
const canonical = [method, path, canonicalQuery, appId, credentialId, timestamp, nonce, bodyHash].join('\n')
const signature = hmacSha256(appSecret, canonical).toLowerCaseHex()

服务端默认接受前后 300 秒(5 分钟)的时间戳,并拒绝重复 Nonce。注意以下两点:

  1. Nonce 在签名校验通过后即被消费,有效期 300 秒。即使本次请求最终返回 5xx 或业务失败,只要签名正确,该 nonce 就已被占用,不能重试同一请求——重试必须生成新的 nonce(同时更新 X-Open-Timestamp 与 X-Open-Signature)。
  2. 签名校验发生在应用状态校验之后:应用不是 ACTIVE 时返回 403(400005),此时不会消耗 nonce。

签名测试向量​

可用下列固定输入自测实现是否正确(appSecret = "test-secret",请求体 {"a":1}):

项值
METHODPOST
REQUEST_PATH/api/open/v2/bot/messages
CANONICAL_QUERY(空字符串)
APP_IDapp_test
CREDENTIAL_IDcred_test
TIMESTAMP1700000000000
NONCEabc123
bodyHash(body={"a":1} 的 SHA-256 小写十六进制)015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862

规范请求串(八行,末尾无换行,共 136 字节):

POST
/api/open/v2/bot/messages

app_test
cred_test
1700000000000
abc123
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862

HMAC-SHA256("test-secret", 规范请求串) 小写十六进制结果为:

453c2fa3608897dd224e065747f9f035bafc7ada564cc807d1dacae9fdb8a51f

请用相同的八行拼接方式对上述输入自测;如果你的输出与之一致,说明第 3 行(空查询串)与第 8 行(原始字节哈希)的处理正确。

签名测试向量(multipart 上传)​

上传接口的签名只要把第 2 行换成上传路径、第 8 行换成拼好的 multipart 字节的哈希即可。 下面这份输入可直接复现(appSecret = "test-secret",分隔符固定为 ----limBoundary7f3a91c2, 文件内容为 15 字节 ASCII hello-multipart,文件名 a.png、Content-Type: image/png):

项值
METHODPOST
REQUEST_PATH/api/open/v2/bot/files
CANONICAL_QUERY(空字符串)
APP_IDapp_test
CREDENTIAL_IDcred_test
TIMESTAMP1700000000000
NONCEdef456
bodyHash(multipart 体 163 字节的 SHA-256 小写十六进制)e3f72feb3b048b85d8bbbd9e614053d93dcd38a41ea05723abad07bc064e2f04

需要参与哈希的请求体共 163 字节,逐字节展开如下(␍␊ 即 \r\n,开头无空行、结尾保留 ␍␊):

------limBoundary7f3a91c2␍␊
Content-Disposition: form-data; name="file"; filename="a.png"␍␊
Content-Type: image/png␍␊
␍␊
hello-multipart␍␊
------limBoundary7f3a91c2--␍␊

规范请求串(八行,末尾无换行,共 133 字节):

POST
/api/open/v2/bot/files

app_test
cred_test
1700000000000
def456
e3f72feb3b048b85d8bbbd9e614053d93dcd38a41ea05723abad07bc064e2f04

HMAC-SHA256("test-secret", 规范请求串) 小写十六进制结果为:

9765639f56f671947add6a064b7b84104dd51774ec40c250b26dff810bb3d2de

拼接与签名的参考实现(Python):

import hashlib, hmac

boundary = "----limBoundary7f3a91c2"
file_bytes = b"hello-multipart"

# 1. 先拼出确定字节的 multipart 请求体
body = b"".join([
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="file"; filename="a.png"\r\n',
b"Content-Type: image/png\r\n",
b"\r\n",
file_bytes,
f"\r\n--{boundary}--\r\n".encode(),
])

# 2. 对拼好的字节算哈希
body_hash = hashlib.sha256(body).hexdigest()

# 3. 八行规范串 -> 签名
canonical = "\n".join(["POST", "/api/open/v2/bot/files", "", "app_test",
"cred_test", "1700000000000", "def456", body_hash])
signature = hmac.new(b"test-secret", canonical.encode(), hashlib.sha256).hexdigest()

# 4. 原样发送拼好的字节,并让 Content-Type 里的 boundary 与之一致
# 把第 1 步的 body 原样写到 body.bin,再发送:
curl -X POST 'https://im.lanchr.com/api/open/v2/bot/files' \
-H 'Content-Type: multipart/form-data; boundary=----limBoundary7f3a91c2' \
-H 'X-Open-App-Id: <app-id>' \
-H 'X-Open-Credential-Id: <credential-id>' \
-H 'X-Open-Timestamp: <timestamp-ms>' \
-H 'X-Open-Nonce: <unique-nonce>' \
-H 'X-Open-Signature: <hmac-sha256>' \
--data-binary @body.bin

关键点是先定字节、再签名、原样发送。只要这三步用的是同一份字节,boundary 取什么值都能通过。

错误响应契约​

失败响应统一为 ApiResult({code, message, value, reqNo}),成功以 code == "000000" 判定。区别在于失败落在哪个 HTTP 状态:

  • 统一失败响应体(鉴权失败、参数问题、业务守卫拒绝):
{
"code": "400008",
"message": "消息未投递给此应用",
"value": null,
"reqNo": "9f1c2d3e4a5b4c6d8e7f0a1b2c3d4e5f"
}
  • HTTP 状态(请求头校验、签名、限流等由鉴权过滤器直接返回):
HTTPcode触发场景
400400006请求体/参数校验失败(缺必填字段、类型转换失败、非法枚举)
401400001缺少请求头、时间戳超窗、应用不存在、凭证无效(如轮换后已过 24 小时宽限期的旧凭证)、签名不匹配、nonce 重复
403400005应用未处于 ACTIVE(未发布、审核中、已停用)
403400007缺少必需的 Scope
403400008业务守卫拒绝(资源不属于当前应用、机器人未安装到目标、fileKey 无效或已过期)
403400101客服 V1:访客已停用
404400100 / 400300 / 400401客服 V1:访客不存在 / 会话不存在或关闭失败 / 客服账号不存在
406406001请求头 Accept 无法被满足(接口只返回 application/json)
409400400客服 V1:分配客服失败
413413001请求体超过限制(普通请求约 256 KiB;机器人文件上传约 22 MiB)
415415001请求体 Content-Type 不受支持(常见于漏发 Content-Type: application/json)
429429001应用配额(每分钟/每日)或并发超限
500500000平台内部错误
  • Content-Type 缺失不是 500:带 JSON 请求体但未声明 Content-Type: application/json 时,平台返回 415 + 415001,属于可自行修正的客户端问题。
  • 客服 V1 的业务失败也是 4xx:访客不存在、会话不存在、访客已停用等一律以对应的 4xx + 业务 code 返回,旧的「HTTP 200 + 业务 code」与「HTTP 500 + 500000」两种行为均已取消。唯一例外是「查无此人」类结果:agent/status(exists:false)与 message/list(空列表)仍返回 200 + code 000000。判据始终是 code == "000000"。
  • 控制台接口(/api/open/console/**)使用各自的错误契约,不适用本节。
  • 响应中的 reqNo 是排障的唯一凭据,请写入日志。

reqNo 是排障的唯一凭据,请写入日志。完整状态码与错误码清单见错误与限制。

安全边界​

App Secret、Webhook Secret 和刷新凭证只能在服务端保存。文档站不提供把 Secret 发送到浏览器的在线调试器;请使用本地服务端脚本测试签名。