Files
lislgosms/docs/client-http-api-guide.md
T
hectorzhao a420d61b23
CSS quality / css-quality (push) Has been cancelled
fix: validate HTTP dates IPv6 URLs and parser errors
2026-09-14 15:15:58 +08:00

1164 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户 HTTP 短信接口接入手册
**接口版本:v1 · 手册修订:2026-09-14**
适用对象:需要从自己的业务系统发送短信、查询短信结果或接收用户回复的开发人员。
本文说明 HTTP 接口的接入步骤、鉴权、请求与响应以及回调处理。所有号码、消息编号和响应示例均为说明用途,不是真实客户数据。GET 空请求体使用第 3 节规定的兼容摘要;每次请求都须重新生成请求唯一标识和签名。
建议先完成上行查询与签名校对,再接入发送和回调。完整命令与报文示例见第 11 节;复制示例不会执行请求。
## 1. 开始接入前
### 1.1 这套接口能做什么
| 你的业务需求 | 使用的能力 |
| --- | --- |
| 向一个手机号发送一条短信 | 单条发送接口 |
| 知道短信是否提交、送达或失败 | 短信状态查询接口 |
| 短信有回执时,由平台主动通知你的系统 | 回执回调(Webhook) |
| 获取手机用户回复的短信 | 上行列表、上行详情,或上行回调 |
“上行短信”就是手机用户回复到短信接入号的内容。“回执”是短信提交后的送达或失败结果。两者不是同一种通知。
本版只提供四个公开业务接口,不提供公开的批量发送、余额查询、签名管理或模板管理接口。签名、模板和应用配置通过平台页面办理。
### 1.2 开户、开通和凭据
依次完成以下准备:
1. 由平台创建企业账号和企业应用,并开通该应用的 HTTP 接口及需要的发送、查询、回调能力。
2. 登录客户端,进入 **接口对接**,选择正确的企业应用。在“接口概览”确认接口地址、能力、QPS、时间容差和 HTTP IP 白名单。
3. 在“访问凭据”创建凭据,保存 **Access Key****Secret**。自助创建未开放时联系平台人员。
4. 如果需要发送,先完成签名和模板审核、必要报备以及余额和可用路由准备。只开通 HTTP 不代表任意正文都可以发送。
5. 如果需要回调,在“回调配置”分别保存回执、上行 HTTPS 地址,并保存各自的回调签名密钥。
Secret 只在创建或轮换时展示一次,后续末四位不能用于签名。凭据属于特定企业应用,不要将应用 A 的凭据用于查询应用 B 的短信。
| 名称 | 用途 | 是否放进请求头 |
| --- | --- | --- |
| Access Key | 标识调用哪个企业应用的接口 | 放入 `X-App-Key` |
| Secret | 在你的服务端计算请求签名 | 不直接发送 |
| 回调签名密钥 | 验证平台推送到你系统的通知 | 不直接发送;与请求 Secret 分开保存 |
| 平台登录密码 | 登录客户端网页 | 不用于 HTTP 接口签名 |
凭据应由你的后端服务保管,不放在网页前端代码、公开仓库或日志中。轮换请求凭据时,先建立新凭据、切换调用,再吊销旧凭据;可同时有效的数量以应用配置为准。回调密钥轮换需协调接收端,当前不要假设平台同时用新旧密钥签名。
### 1.3 地址与文档入口
| 用途 | 地址 |
| --- | --- |
| 客户接口基础地址 | `https://api.lisglo.com/api/openapi/v1` |
| 在线 Swagger | https://api.lisglo.com/api/client-docs |
| OpenAPI JSON | https://api.lisglo.com/api/client-docs-json |
| 客户端文档入口 | 接口对接 → 选择企业应用 → 接口文档 |
上述域名当前连接预生产真实业务系统。未开户也能查看文档,但业务调用需要有效凭据;Swagger 的调试按钮会发真实请求,不是匿名试用或模拟发送。测试环境使用平台另行提供的地址和凭据,不能混用。
**建议第一次先查询上行列表。** 即使没有上行数据,也可以用空列表确认接入流程;不要用发送短信来测试网络是否通畅。
## 2. 四个接口一览
以下路径均在域名 `https://api.lisglo.com` 后拼接,已包含 `/api`,不要重复添加。
| 方法 | 完整路径 | 成功状态 |
| --- | --- | --- |
| POST | `/api/openapi/v1/sms/messages` | 202:请求受理 |
| GET | `/api/openapi/v1/sms/messages/{messageId}` | 200:返回短信状态 |
| GET | `/api/openapi/v1/sms/uplinks` | 200:返回上行列表 |
| GET | `/api/openapi/v1/sms/uplinks/{uplinkId}` | 200:返回上行详情 |
所有接口都需要下面的鉴权头。请求和响应使用 UTF-8;POST 请求体使用 JSON。成功响应直接返回对象,没有额外的 `data` 包装层;回调通知则有自己的 `data` 字段。
## 3. 每次请求怎样签名
### 3.1 请求头
| 请求头 | 必填 | 填写方式 |
| --- | --- | --- |
| `X-App-Key` | 是 | 该应用的 Access Key |
| `X-Timestamp` | 是 | 当前 Unix 时间戳,单位为秒,例如 `1789344000`;不要传毫秒 |
| `X-Nonce` | 是 | 本次 HTTP 请求的唯一标识,推荐 UUID v4,例如 `550e8400-e29b-41d4-a716-446655440000`;兼容原 8128 字符规则 |
| `X-Signature` | 是 | 按下文计算的 HMAC-SHA256,小写十六进制字符串 |
| `Idempotency-Key` | 发送时必填 | 一次业务发送的稳定编号,8~128 字符,允许字母、数字、`.``_``:``-` |
| `Content-Type` | POST 使用 | `application/json` |
| `User-Agent` | 否 | 可填写客户端名称和版本,便于排错 |
默认时间容差为正负 300 秒,具体以应用配置为准。服务器时钟应同步。每次请求包括网络重试都要生成新的 nonce;即使业务请求被限流,旧 nonce 也不能继续使用。
推荐用标准库生成 UUID v4,例如 Python `str(uuid.uuid4())`。当前服务端仍兼容字母、数字、下划线和短横线组成的 8~128 字符,不要求旧客户全部改成 UUID。不要使用订单号或仅有时间戳的值代替每次请求的新标识。
| 标识 | 代表的对象 | 同一次发送的网络重试 |
| --- | --- | --- |
| `X-Nonce`(请求唯一标识) | 一次 HTTP 调用,防止请求重放 | 重新生成 UUID v4 |
| `Idempotency-Key`(业务幂等键) | 一次业务发送,防止重复创建短信 | 保持不变,并保持 body 原始字节相同 |
| `clientMessageId`(客户短信编号) | 客户系统中的短信业务记录 | 保持不变 |
### 3.2 签名原文
按下面的固定顺序拼接字符串;`BODY_HASH` 是第 3.3 节或 POST 原始字节得到的摘要:
```text
SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH
SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING)))
```
公式中的 `"\n"` 是程序语言表示法,指一个 LF 字节 `0x0A`,不是反斜杠和字母 n 两个字符,也不是 Windows 的 CRLF`0x0D 0x0A`)。这是签名字符串内部的规则,与 HTTP 报文的行结束符不同。最后不加换行、不加空格、不加引号。
`&` 仍用于实际 URL 的 query 参数连接,例如 `limit=25&cursor=...`,不用于替换本接口签名原文的分隔符。当前 query 不参与签名;保持此规则,不在文档修订中暗中改变协议。
以下是同一个签名原文的分行展示,便于逐项核对:
```text
大写 HTTP 方法
请求路径
X-Timestamp 的原始字符串
X-Nonce 的原始字符串
请求体的 SHA256 十六进制摘要
```
然后用 **Secret 的 UTF-8 字节**作为 HMAC 密钥,对这五行的 UTF-8 字节计算 HMAC-SHA256,输出 64 位十六进制字符串。
具体约定:
- 方法使用 `GET``POST`
- 路径包含 `/api`,不包含域名、`?` 和查询字符串。路径参数需要 URL 编码时,按实际发送的编码后路径签名。
- POST 必须对**最终发送的原始 JSON 字节**取摘要。签名后不要再格式化、改变空格、调整字段顺序或改变中文转义方式。
- Secret 按创建时取得的字符串直接使用,不先进行 Base64 解码。
- 示例使用纯十六进制签名。当前服务端也接受 `sha256=` 前缀,但不要求添加。
- Idempotency-Key 是单独的业务幂等头,不是 nonce,也不是上述五行之一。
### 3.3 当前 GET 空请求体的兼容规则
**当前版本的特殊行为:不带请求体的 GET,摘要需要按 UTF-8 字符串 `{}` 计算。GET 本身仍不发送请求体。**
固定摘要为:
```text
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
```
这与通常“空请求体按空字节计算 SHA256”的规则不同,也是当前页面只写 `SHA256(rawBody)` 容易导致验签失败的原因。本节是现版本兼容说明,不是理想协议的新设计。后续修正必须由平台说明兼容策略,本稿示例按现行为编写。
### 3.4 Python 签名函数
下面只计算请求头,不访问网络。使用 Python 标准库即可。
```python
import hashlib
import hmac
import uuid
import time
def make_headers(method, path, raw_body, access_key, secret,
timestamp=None, nonce=None):
# path 必须是实际发送的路径,不包含域名和 query。
if not path.startswith("/api/openapi/v1/") or "?" in path or "#" in path:
raise ValueError("请传入不含 query 的完整接口路径")
timestamp = str(int(time.time()) if timestamp is None else timestamp)
nonce = str(uuid.uuid4()) if nonce is None else nonce
# None 表示 GET 无请求体;兼容当前服务端的 {} 摘要规则。
bytes_to_hash = b"{}" if raw_body is None else raw_body
body_hash = hashlib.sha256(bytes_to_hash).hexdigest()
source = "\n".join([method.upper(), path, timestamp, nonce, body_hash])
signature = hmac.new(
secret.encode("utf-8"), source.encode("utf-8"), hashlib.sha256
).hexdigest()
return {
"X-App-Key": access_key,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
}
```
不要把函数生成的请求头或真实 Secret 完整打印到日志。
### 3.5 固定签名校对样例(不用于线上请求)
使用虚构 Secret `doc-example-secret`、时间戳 `1789344000`、nonce `550e8400-e29b-41d4-a716-446655440000`GET 路径 `/api/openapi/v1/sms/uplinks`,不发送 body。按当前兼容规则,实际签名字符串为:
```text
GET
/api/openapi/v1/sms/uplinks
1789344000
550e8400-e29b-41d4-a716-446655440000
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
```
上面共有 4 个 LF 分隔字节,结尾没有 LF。期望签名为:
```text
f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65
```
使用第 3.4 节函数并显式传入上述 timestamp 和 nonce,应得到完全相同的 `X-Signature`。这些固定值只用于离线校对;真实请求使用当前时间、新 UUID 和自己的凭据。
## 4. 第一个调用:查询最近的上行短信
将第 3.4 节函数和下面代码放在同一个 Python 文件。通过本机安全配置提供 `CMPP_HTTP_ACCESS_KEY``CMPP_HTTP_SECRET` 环境变量,再运行脚本。此代码只执行 GET,不发送短信。
```python
import json
import os
import urllib.error
import urllib.parse
import urllib.request
origin = "https://api.lisglo.com"
path = "/api/openapi/v1/sms/uplinks"
query = urllib.parse.urlencode({"limit": 10})
headers = make_headers(
"GET", path, None,
os.environ["CMPP_HTTP_ACCESS_KEY"],
os.environ["CMPP_HTTP_SECRET"],
)
headers["Accept"] = "application/json"
request = urllib.request.Request(
origin + path + "?" + query, headers=headers, method="GET"
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
result = json.loads(response.read().decode("utf-8"))
# 只输出计数,不在示例中打印客户手机号和短信正文。
print("HTTP", response.status)
print("本页条数:", len(result["items"]))
print("是否有下一页:", result["nextCursor"] is not None)
except urllib.error.HTTPError as error:
try:
problem = json.loads(error.read().decode("utf-8"))
except (ValueError, UnicodeError):
problem = {}
print("HTTP", error.code, "业务码:", problem.get("code", "非标准错误响应"))
except urllib.error.URLError:
print("连接失败,请检查网络、域名及 HTTPS 连通性")
```
没有匹配到上行数据时,正常返回:
```json
{"items": [], "nextCursor": null}
```
空列表不是失败。401 优先检查签名,403 检查应用能力与白名单,429 降低请求频率。
## 5. 发送单条短信
### 5.1 请求
`POST /api/openapi/v1/sms/messages`
完整 cURL 请求和 HTTP 响应见第 11.2 节。
除鉴权头外,需要 `Content-Type: application/json``Idempotency-Key`
| JSON 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `mobile` | string | 是 | 单个中国大陆手机号,例如 `13800138000`;不能传数组或逗号分隔号码 |
| `content` | string | 是 | 完整短信正文,包含签名和已填入的变量值;不能为空 |
| `clientMessageId` | string | 否,建议提供 | 你的业务系统消息编号,应用内唯一;按不超过 128 字符使用 |
```json
{
"mobile": "13800138000",
"content": "【示例签名】您的验证码是123456,5分钟内有效。",
"clientMessageId": "order-20260914-0001"
}
```
正文由平台识别签名、模板和变量,不传 `tenantId``applicationId``signatureId``templateId` 或自选通道。上例签名和模板仅作说明,发送前必须使用你已审核且符合发送条件的内容。
下面代码只准备请求,不执行发送;接在第 3.4 节函数之后使用:
```python
import json
payload = {
"mobile": "13800138000",
"content": "【示例签名】您的验证码是123456,5分钟内有效。",
"clientMessageId": "order-20260914-0001",
}
raw_body = json.dumps(
payload, ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
path = "/api/openapi/v1/sms/messages"
# access_key、secret 从你的安全配置读取。
headers = make_headers("POST", path, raw_body, access_key, secret)
headers["Content-Type"] = "application/json"
headers["Idempotency-Key"] = "sms-order-20260914-0001"
# 由你的发送代码将同一个 raw_body 原样交给 HTTP 客户端。
# 不要让 HTTP 客户端再把 payload 重新序列化成另一组字节。
```
### 5.2 受理响应
成功受理返回 HTTP **202**,示例:
```json
{
"code": "ACCEPTED",
"requestId": "req_11111111-1111-4111-8111-111111111111",
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"clientMessageId": "order-20260914-0001",
"status": "queued",
"acceptedAt": "2026-09-14T02:00:00.000Z"
}
```
| 字段 | 含义 |
| --- | --- |
| `code` | `ACCEPTED` 表示接口受理成功 |
| `requestId` | 本次业务请求编号,排查问题时提供给平台 |
| `messageId` | 平台短信编号,用于后续查询;请持久保存 |
| `clientMessageId` | 你传入的编号,未提供时为 null |
| `status` | 当前短信或任务状态,不保证总是 queued,也可能需要审核 |
| `acceptedAt` | 受理响应时间,ISO 8601;示例中的 Z 表示 UTC |
**202 ≠ 已提交运营商 ≠ 手机已收到。** 请用状态查询或回执判断结果;HTTP 200/202 也不能直接解释为计费成功或退款完成。若 202 缺少 messageId,保留 requestId 和 clientMessageId 联系平台核查,不因缺失就创建新发送。
### 5.3 重复请求与超时处理
| 情况 | 处理方式 |
| --- | --- |
| 网络超时,不确定平台是否受理 | 先按 clientMessageId 查询;必要时用原 Idempotency-Key 和完全相同的 body 重试请求,重新生成 timestamp/nonce/签名 |
| 相同幂等键、相同 body,原请求已完成 | 返回原受理响应,原编号和 acceptedAt 保持不变 |
| 相同幂等键、相同 body,原请求业务失败已保存 | 重放原失败,不会因稍后改好配置就自动重新发送 |
| 相同幂等键、不同 body | 409 `IDEMPOTENCY_CONFLICT` |
| 原请求仍处理中 | 409 `REQUEST_PROCESSING`;稍后查询,长期不结束联系平台 |
| 换了幂等键,但 clientMessageId 已关联短信 | 409 `CLIENT_MESSAGE_ID_CONFLICT` |
同一业务短信的幂等键要持久保存。JSON 字段顺序、空格变化也可能被视为不同 body。不要为了绕过 409 换键反复发送。修正确定失败的业务后是否发起新发送,由你的业务流程明确决定。
## 6. 查询短信状态
完整 cURL 请求和 HTTP 响应见第 11.3 节。
`GET /api/openapi/v1/sms/messages/{messageId}`,无请求体。
路径末尾可以填写平台返回的 messageId,也支持原来提交的 clientMessageId。请对路径参数做 URL 编码;只能查询当前凭据所属应用的数据。
示例响应:
```json
{
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"clientMessageId": "order-20260914-0001",
"phoneNumber": "13800138000",
"status": "delivered",
"submitStatus": "accepted",
"receiptStatus": "delivered",
"errorCode": null,
"errorMessage": null,
"queuedAt": "2026-09-14T02:00:00.000Z",
"submittedAt": "2026-09-14T02:00:01.000Z",
"deliveredAt": "2026-09-14T02:00:03.000Z",
"updatedAt": "2026-09-14T02:00:03.100Z"
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `messageId` | string | 平台短信编号 |
| `clientMessageId` | string/null | 客户消息编号 |
| `phoneNumber` | string | 接收手机号;注意响应字段不是 mobile |
| `status` | string | 平台业务状态 |
| `submitStatus` | string/null | 提交状态,如 queued、accepted、rejected、timeout |
| `receiptStatus` | string/null | 回执状态,如 delivered、undelivered、unknownnull 表示尚无结果 |
| `errorCode` / `errorMessage` | string/null | 失败码与说明,未发生或暂无时可为空 |
| `queuedAt` / `updatedAt` | string | 入队记录时间、最近更新时间 |
| `submittedAt` / `deliveredAt` | string/null | 提交时间、结果时间,尚无数据时可为空 |
常见 `status` 的业务含义:
| 状态 | 你应如何理解 |
| --- | --- |
| `pending_review` | 等待审核,不代表已送出 |
| `queued` / `submit_queued` | 等待平台处理或向通道提交 |
| `submitted` | 已提交,等待明确回执 |
| `delivered` | 已获得送达结果 |
| `submit_failed` / `failed` / `rejected` | 提交失败、发送失败或业务拒绝,结合错误字段查看原因 |
| `unknown` / `timeout` | 结果未知或等待超时,不能当成送达 |
上述是常见值,不是服务端严格冻结的全部枚举。遇到其他值保留原值并展示“待确认”,不要默认成功。`deliveredAt` 在失败回执中也可能是结果发生时间,不能只看这个字段非空就认为送达。
不存在或不属于本应用时返回 404 `MESSAGE_NOT_FOUND`。查询刚超时的发送得到 404 也不能单独证明业务永远不会落库。
## 7. 查询手机用户回复(上行)
### 7.0 先分清四种时间边界
| 项目 | 当前规则 | 举例 |
| --- | --- | --- |
| 默认查询范围 | endTime 默认现在;startTime 默认 endTime 前 24 小时 | 什么时间都不传,就是最近 24 小时 |
| 单次最大查询跨度 | 默认 31 天,以应用配置为准;限制起止时间之差 | 可查询三个月前某一天,并不是只能查询最近 31 天 |
| 历史数据保留期 | 有默认 90 天的配置,但尚未确认存在按该配置执行的保留/清理闭环 | 不能承诺一定有 90 天数据,也不能断言更早数据必定被删除 |
| 游标有效期 | 当前游标没有单独过期计时;不保证数据长期不变 | 不将旧 cursor 当作永久同步凭据,重新固定时间窗口并去重 |
查询代码没有额外强制“只查最近 N 天”,但历史查询能否返回数据取决于实际留存、当前应用归属和筛选条件。startTime 必须不晚于 endTime,当前时间边界两端均包含。跨窗口同步可按上行 id 去重,避免公共边界重复计入。
时间接受 `YYYY-MM-DD`(按UTC零点)或带 `Z` / `±HH:mm` 时区的ISO 8601时间;秒可省略,小数秒最多三位。日期必须在日历上真实存在,不接受2月30日、24:00、无时区日期时间或本地化日期格式;空字符串也不是省略参数。游标中的时间执行同样校验。
### 7.1 列表与筛选
第一页、空数据及下一页的完整报文见第 11.4~11.5 节。
`GET /api/openapi/v1/sms/uplinks`,无请求体。
| Query 参数 | 类型 | 默认值/规则 |
| --- | --- | --- |
| `startTime` | ISO 8601 时间字符串 | 默认 endTime 向前 24 小时 |
| `endTime` | ISO 8601 时间字符串 | 默认当前时间 |
| `mobile` | string | 可选,精确匹配回复者手机号 |
| `accessNumber` | string | 可选,精确匹配回复到的接入号,对应响应 destId |
| `keyword` | string | 可选,按回复正文包含关键词筛选 |
| `limit` | 正整数 | 默认 50;不超过应用最大分页配置,系统默认上限 100 |
| `cursor` | string | 第一页不传,下一页使用上一响应 nextCursor |
单次时间跨度默认上限 31 天,以应用配置为准。建议总是带时区,例如 `2026-09-14T00:00:00+08:00``2026-09-13T16:00:00Z`URL 中的 `+` 应通过标准 query 编码转换为 `%2B`。空筛选项直接省略,不传空字符串。limit 按上述正整数约定使用,不依赖当前不完善的非法参数处理。
示例请求 URL
```text
https://api.lisglo.com/api/openapi/v1/sms/uplinks?startTime=2026-09-13T16%3A00%3A00Z&endTime=2026-09-14T16%3A00%3A00Z&limit=25
```
签名的路径仍为 `/api/openapi/v1/sms/uplinks`,不包含 `?` 后的内容。
示例响应:
```json
{
"items": [
{
"id": "uplink-example-001",
"messageId": null,
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z"
}
],
"nextCursor": null
}
```
`id` 是上行记录编号,用它查询详情;`messageId` 可能为空,不用它代替上行 id。`destId` 是接收用户回复的短信接入号。内部匹配状态与诊断不对外返回。只返回归属本应用的已匹配/已认领数据,不返回未匹配或存在归属歧义的数据。
### 7.2 翻页方法
1. 第一页固定 startTime、endTime 和筛选条件,不传 cursor。
2. 若 nextCursor 非 null,下一次保持时间、筛选和 limit 不变,只加入 cursor;仍需新的 timestamp/nonce/签名。
3. nextCursor 为 null 时结束。没有总条数、页码或 totalPages。
记录按接收时间、记录 id 倒序返回。cursor 是不透明令牌,不自行解码、构造或当作长期同步水位。上行可能延迟到达或稍后才被认领;持续同步宜重叠查询时间窗口并按 id 去重,不把一次分页当作永不变化的快照。
### 7.3 上行详情
完整 cURL 请求和 HTTP 响应见第 11.6 节。
`GET /api/openapi/v1/sms/uplinks/{uplinkId}`,无请求体。
把列表的 `items[].id` 或回调的 `data.uplinkMessageId` 放入路径,按第 3 节签名。返回单个上行对象,核心字段与列表中的 item 相同,不再包裹 items。
详情额外返回当前客户自己的 `tenantId``applicationId`。不返回通道、供应商编号、内部事件/匹配诊断、数据库关联行号等字段。本次按数据边界收紧旧输出,旧客户需移除对这些内部字段的依赖。不存在或不属于当前企业应用时返回 404 `UPLINK_NOT_FOUND`
## 8. 平台怎样主动通知你的系统
### 8.1 配置与接收
在客户端“回调配置”分别设置:
- **回执回调 URL**:接收 `receipt`,用于获知短信结果。
- **上行回调 URL**:接收 `uplink`,用于接收用户回复。
平台向对应 URL 发起 HTTP POST。需开通该类回调能力、保存非空有效地址。使用平台可访问的公网 HTTPS 地址,不使用本机、内网或依赖重定向的地址。每个回调端点使用自己的签名密钥。
不要假设每次发送请求都会有回调。同步参数错误、未受理的请求以及未生成该类事件的分支,应处理接口响应或查询结果。长短信的 HTTP 结果通知按业务消息处理,不按运营商内部计费分片逐片回调。
### 8.2 请求头和事件格式
两类通知的完整 HTTP 请求及接收端响应见第 11.7 节。
| 回调头 | 说明 |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Event-Id` | 稳定事件编号,用于去重 |
| `X-Event-Type` | `receipt``uplink` |
| `X-Timestamp` | 本次投递的 Unix 秒级时间戳 |
| `X-Signature` | `sha256=` 加 HMAC-SHA256 十六进制值 |
回执示例:
```json
{
"eventId": "evt_receipt_example001",
"eventType": "receipt",
"occurredAt": "2026-09-14T02:00:03.100Z",
"data": {
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"gatewayMessageId": "example-gateway-id",
"phoneNumber": "13800138000",
"receiptStatus": "delivered",
"rawStatus": "DELIVRD",
"errorCode": null,
"deliveredAt": "2026-09-14T02:00:03.000Z"
}
}
```
`receiptStatus` 表示结果,`rawStatus` 是原始状态,`errorCode` 可空,部分失败事件还含 `errorMessage`。网关编号只作为辅助信息,使用 messageId 与自己的发送记录关联。当前回执不保证带 clientMessageId,请保存发送响应中的两种编号映射。未提供的可选字段可能缺省,也可能为 null。
上行示例:
```json
{
"eventId": "evt_uplink_example001",
"eventType": "uplink",
"occurredAt": "2026-09-14T02:05:00.100Z",
"data": {
"applicationId": "application-example",
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z",
"uplinkMessageId": "uplink-example-001"
}
}
```
上行 data 还可能包含 messageId,人工认领的通知可含 `manualClaim: true`;没有 messageId 时不能强行关联一条发送记录。`occurredAt` 是平台创建事件的时间,与数据里的 deliveredAt/receivedAt 含义不同;重试时事件编号和事件体保持不变,投递时间戳与签名会重新生成。
### 8.3 如何验签
回调的签名原文是 **时间戳 + 一个 LF 换行 + 原始请求体字节**,使用该回调端点的签名密钥执行 HMAC-SHA256。它不是第 3 节的五行请求签名。
先保留 HTTP 原始 body 再验签,不要解析 JSON 后重新序列化。下面是纯计算示例;header 名大小写不敏感:
```python
import hashlib
import hmac
import json
import time
def verify_callback(headers, raw_body, webhook_secret, now=None,
allowed_skew_seconds=300):
# 300 秒是本示例的接收端策略,可按双方约定调整。
hs = {str(k).lower(): str(v) for k, v in headers.items()}
timestamp = hs.get("x-timestamp", "")
if not timestamp.isdigit():
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > allowed_skew_seconds:
return False
supplied = hs.get("x-signature", "")
if not supplied.startswith("sha256="):
return False
source = timestamp.encode("utf-8") + b"\n" + raw_body
expected = hmac.new(
webhook_secret.encode("utf-8"), source, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, supplied[7:]):
return False
try:
event = json.loads(raw_body)
except (ValueError, UnicodeError):
return False
if not isinstance(event, dict):
return False
return (
isinstance(event.get("eventId"), str)
and bool(event["eventId"])
and event.get("eventId") == hs.get("x-event-id")
and event.get("eventType") == hs.get("x-event-type")
and event.get("eventType") in {"receipt", "uplink"}
and isinstance(event.get("data"), dict)
)
```
验签后,仍需在数据库以 eventId 做唯一约束或等效的原子去重,不能只用进程内集合。对重复且已成功保存的事件正常确认;不要因重复而再次更新余额、发券或触发另一条短信。不同事件的到达先后也不能代替业务状态判断。
### 8.4 怎样确认接收、失败后会怎样
平台把 **任意 HTTP 2xx** 视为接收成功,不要求固定响应 JSON;可以返回 `200 OK`。建议先验签并将事件可靠保存,再尽快返回 2xx,后续业务异步处理。返回了 2xx 即使正文写“失败”,平台也按成功处理。
按设计:网络错误、408、429、5xx 可重试;其他 4xx 终结,重定向不跟随。默认总尝试上限 7 次(含首次),预期各次失败后的间隔为 1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时,并受应用策略限制。这些是相邻尝试的等待间隔,不是统一从首次起算。默认配置超时为 10 秒,不应依赖它作为严格端到端时限。
整改后的新回调使用稳定任务编号和数据库耐久待办;Redis暂不可用时保留待办,由后续扫描恢复。每次投递按数据库租约认领,尝试结果与下一次等待时间同事务保存。网络中断可能造成重复接收,仍须按eventId幂等。历史pending/retrying不会在升级时自动补投,须另行核对处理;部署与验收完成情况见测试进度。
## 9. 常见错误与处理
由业务接口异常过滤器处理的错误,通常使用 `application/problem+json`
```json
{
"type": "https://cmpp-platform.local/problems/signature_invalid",
"title": "UNAUTHORIZED",
"status": 401,
"code": "SIGNATURE_INVALID",
"detail": "请求签名校验失败"
}
```
以 HTTP 状态和 code 做程序判断,detail 供人阅读;type 当前是标识符,不是可访问的帮助链接。代理、网络或 JSON 解析层也可能返回其他格式,应先检查 Content-Type,不要直接假设每次失败都能解析 JSON。
| HTTP | code | 处理方法 |
| --- | --- | --- |
| 400 | `PARAMETER_INVALID` / `LIMIT_INVALID` | 检查字段类型、长度和正整数分页 |
| 409 | `REQUEST_REQUIRES_REVIEW` | 提供requestId核对,不更换幂等键重发 |
| 400 | `MOBILE_INVALID` / `CONTENT_REQUIRED` | 检查单个手机号和正文 |
| 400 | `IDEMPOTENCY_KEY_INVALID` | 补齐有效幂等键,检查长度与字符 |
| 400 | `TIME_RANGE_INVALID` / `TIME_RANGE_TOO_LARGE` | 检查时间格式、先后顺序和跨度 |
| 413 | `PAYLOAD_TOO_LARGE` | JSON请求体超过2 MiB限制,减少内容后再提交 |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | 检查请求体编码及字符集,使用UTF-8 JSON |
| 400 | `CURSOR_INVALID` | 使用平台返回的 cursor,保持筛选条件一致 |
| 401 | `AUTH_HEADERS_MISSING` | 补齐四个鉴权头 |
| 401 | `CREDENTIAL_INVALID` | 核对应用、Access Key、有效期和吊销状态 |
| 401 | `NONCE_INVALID` / `NONCE_REPLAYED` | 检查格式,每次调用生成新的 nonce |
| 401 | `TIMESTAMP_EXPIRED` | 使用秒级时间戳并同步时钟 |
| 401 | `SIGNATURE_INVALID` | 核对 Secret、五行原文、路径、原始 body 和 GET 兼容摘要 |
| 403 | `HTTP_API_DISABLED` | 应用未启用或未开通 HTTP,联系平台 |
| 403 | `IP_NOT_ALLOWED` | 核对调用机器公网出口 IP 和 HTTP 白名单 |
| 403 | `SEND_NOT_ENABLED` / `MESSAGE_QUERY_NOT_ENABLED` / `UPLINK_QUERY_NOT_ENABLED` | 联系平台开通对应子能力 |
| 404 | `MESSAGE_NOT_FOUND` / `UPLINK_NOT_FOUND` | 核对编号及凭据所属应用 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同键 body 不一致,停止盲重试并核对原请求 |
| 409 | `REQUEST_PROCESSING` | 查询既有业务结果;长期停留联系平台 |
| 409 | `CLIENT_MESSAGE_ID_CONFLICT` | 查询已有客户编号对应记录,避免重复业务发送 |
| 422 | `SEND_REJECTED` | 正文、审核或其他业务条件不满足,按说明处理,不盲重试 |
| 429 | `QPS_LIMIT_EXCEEDED` | 应用共享限流,降低并发、延后并加随机退避;新凭据不能扩大应用额度 |
| 5xx | `REQUEST_FAILED` / `INTERNAL_ERROR` 等 | 保留请求标识和时间联系平台;发送场景先查询,避免重新创建业务 |
表格覆盖明确的常见分支,不穷尽发送链下游的所有错误。当前 5xx 文案/首次与重放错误码尚未完全统一,不依赖内部错误文本写业务逻辑。发生连接中断时可能没有任何 HTTP 状态或 JSON,按网络错误处理。
每次交互响应头 `X-Request-Id` 用于定位本次调用;发送受理正文中的 `requestId` 对应稳定业务幂等记录,两者含义不同。未知错误固定为 `INTERNAL_ERROR`,不返回依赖异常正文。
提交排障信息时提供:应用名称、请求时间与时区、接口路径、HTTP 状态、业务 code、requestId/messageId/clientMessageId 或 eventId。手机号和正文按需要脱敏,不提供 Secret 或完整鉴权头。
## 10. 当前版本的接入边界
| 事项 | 当前说明 |
| --- | --- |
| Swagger 准确性 | 查询响应/上行参数未补全,Idempotency-Key 重复、User-Agent 必填及 clientMessageId 类型存在错误;以本文明确的现状说明对照实现 |
| GET 空体 | 当前须使用 `{}` 摘要;后续变更需确认兼容策略 |
| 回调重试 | 新事件耐久待办和安全任务编号;真实验收与部署状态单独记录,历史事件不自动重投 |
| 超时与幂等恢复 | 受理快照与消息/待办同事务;结果无法确定时返回 REQUEST_REQUIRES_REVIEW,不能换幂等键绕过 |
| 参数与响应模型 | clientMessageId为string/null且最长128limit为正整数,按应用上限裁剪;上行不返回内部通道和匹配字段 |
| 性能 | 以应用配置限制请求频率;QPS 配置不是对全链路送达能力的承诺 |
| 文档验证 | 本稿完成源码对照、公开契约读取和离线示例校验;没有使用客户凭据发送或完成真实回调验收 |
## 11. 逐接口 cURL 与 HTTP 报文示例
### 11.1 公共准备与占位符
本节每个接口均提供 cURL 请求、成功和常见失败 HTTP 响应;回调提供平台 POST 和客户 ACK。为便于阅读,HTTP 报文省略 Content-Length、Date、连接等由服务器或 HTTP 客户端生成的头;不要把签名原文的 LF 规则套到 HTTP 报文行结束符上。
cURL 使用 **Bash** 语法(PowerShell 应使用 curl.exe 并调整续行/变量语法)。`$ACCESS_KEY``$TIMESTAMP``$NONCE``$SIGNATURE` 分别对应第 3.4 节函数返回的四个头;`$CURSOR` 必须取上一页真实 nextCursor。这些不是固定测试凭据。下面的可视 cURL 不能在未生成动态鉴权值时直接调用;固定签名只用于第 3.5 节离线校对。
下面的公共辅助函数引用第 3.4 节 `make_headers`,直接生成带动态签名的 cURL 参数和 stdin 字节,免去手工设置上述变量。将它与签名函数放在同一 Python 文件,从安全环境变量读取凭据。**函数本身不访问网络;不要打印返回的完整鉴权参数。**
```python
import os
from urllib.parse import urlencode
def prepare_curl(method, path, raw_body=None, query=None, idempotency_key=None):
method = method.upper()
if method == "POST" and (raw_body is None or not idempotency_key):
raise ValueError("发送需提供最终原始字节和稳定幂等键")
headers = make_headers(method, path, raw_body,
os.environ["CMPP_HTTP_ACCESS_KEY"],
os.environ["CMPP_HTTP_SECRET"])
url = "https://api.lisglo.com" + path
if query:
url += "?" + urlencode(query)
args = ["curl", "--include", "--max-time", "15", "--request", method, url]
for key, value in headers.items():
args.extend(["--header", f"{key}: {value}"])
if raw_body is not None:
args.extend(["--header", "Content-Type: application/json",
"--data-binary", "@-"])
if idempotency_key:
args.extend(["--header", f"Idempotency-Key: {idempotency_key}"])
return args, raw_body
```
例如 `args, body = prepare_curl("GET", "/api/openapi/v1/sms/uplinks", query={"limit": 10})` 只准备查询。由客户在确认环境后使用 `subprocess.run(args, input=body, check=True)` 实际调用;HTTP 状态仍须单独判断,cURL 进程退出 0 不代表业务成功。POST 将完整 JSON 字节通过 stdin 传入,避免重新序列化;本稿没有自动执行发送的入口。
### 11.2 单条发送
将第 5.1 节 JSON 保存为 UTF-8 无 BOM 的 `sms.json`,签名时读取该文件的原始字节,随后原样发送。此命令会真实提交短信,示例不自动执行。
```bash
curl --include --max-time 15 --request POST \
"https://api.lisglo.com/api/openapi/v1/sms/messages" \
--header "X-App-Key: $ACCESS_KEY" \
--header "X-Timestamp: $TIMESTAMP" \
--header "X-Nonce: $NONCE" \
--header "X-Signature: $SIGNATURE" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: sms-order-20260914-0001" \
--data-binary "@sms.json"
```
成功响应示例:
```http
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
{
"code": "ACCEPTED",
"requestId": "req_11111111-1111-4111-8111-111111111111",
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"clientMessageId": "order-20260914-0001",
"status": "queued",
"acceptedAt": "2026-09-14T02:00:00.000Z"
}
```
失败响应示例:
```http
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/idempotency_conflict",
"title": "CONFLICT",
"status": 409,
"code": "IDEMPOTENCY_CONFLICT",
"detail": "同一Idempotency-Key对应的请求内容不一致"
}
```
#### 11.2.1 完整生成脚本:不再手工填写请求头变量
上面的 `$NONCE``$TIMESTAMP` 等是 Bash 变量引用,不是可以原样发送的参数值。未设置变量时,单独复制上面的结构示例不能正常鉴权。
下面提供完整脚本:实际生成 UUID v4、当前时间戳、请求体摘要和 HMAC-SHA256 签名,再输出没有请求头变量占位符的 Bash cURL 命令。保存为 UTF-8 编码的 `generate_sms_curl.py`,运行 `python3 generate_sms_curl.py`
**脚本只生成并打印命令,不访问接口、不发送短信。UUID 和签名是真实生成、真实计算的;Access Key 和 Secret 明确为未开户的演示凭据,不能据此鉴权成功。**
```python
import hashlib
import hmac
import json
import shlex
import time
import uuid
# 演示凭据,不是真实客户账号。
ACCESS_KEY = "DEMO_ACCESS_KEY_NOT_REGISTERED"
SECRET = "DEMO_SECRET_NOT_A_REAL_CREDENTIAL"
origin = "https://api.lisglo.com"
path = "/api/openapi/v1/sms/messages"
# 同一次业务发送重试时,两个业务编号和请求体保持不变。
idempotency_key = "sms-doc-example-20260914-0001"
payload = {
"mobile": "13800138000",
"content": "【示例签名】您的验证码是123456,5分钟内有效。",
"clientMessageId": "doc-example-20260914-0001",
}
# 只序列化一次:计算摘要与最终发送使用同一份内容。
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
raw_body = body.encode("utf-8")
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(raw_body).hexdigest()
signing_string = "\n".join([
"POST", path, timestamp, nonce, body_hash,
])
signature = hmac.new(
SECRET.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-App-Key": ACCESS_KEY,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
"Idempotency-Key": idempotency_key,
}
lines = [
"curl --include --max-time 15 --request POST "
+ shlex.quote(origin + path)
]
for name, value in headers.items():
lines.append("--header " + shlex.quote(f"{name}: {value}"))
lines.append("--data-binary " + shlex.quote(body))
print("本次实际生成的 UUID", nonce)
print("本次时间戳:", timestamp)
print("请求体 SHA256", body_hash)
print("计算得到的签名:", signature)
print("\n完整 cURL 命令(仅生成,未执行):\n")
print(" \\\n ".join(lines))
```
该脚本每次运行都会产生新的 nonce 和时间戳,因而签名也会变化。输出命令按 Bash 和 UTF-8 编写,不应直接当作 PowerShell 语法执行。脚本用于演示,会打印鉴权头;正式接入时凭据从安全配置读取,不把完整鉴权信息写入共享日志。
#### 11.2.2 实际计算的完整 cURL 示例
以下为2026-09-14实际生成的一组结果,不是 `$NONCE``$SIGNATURE` 占位符,也不是成功发送记录:
| 项目 | 本次实际使用或计算的值 |
| --- | --- |
| 演示 Access Key | `DEMO_ACCESS_KEY_NOT_REGISTERED`(未开户) |
| 演示 Secret | `DEMO_SECRET_NOT_A_REAL_CREDENTIAL`(无真实账号权限) |
| Unix 秒级时间戳 | `1789355443` |
| 实际生成的 UUID v4 | `7921b5d1-3b99-48d4-a068-ea7cf0c998db` |
| 原始请求体 SHA256 | `c6cd931342e983778342e2175c0c60eafc87f28c892378761d0e6f7065728a29` |
| 实际计算的 HMAC-SHA256 | `90cd7c99308406868fcdad059a9e61aef0a5d1275756885c4e277aa20e787a4b` |
```bash
curl --include --max-time 15 --request POST \
'https://api.lisglo.com/api/openapi/v1/sms/messages' \
--header 'Content-Type: application/json' \
--header 'X-App-Key: DEMO_ACCESS_KEY_NOT_REGISTERED' \
--header 'X-Timestamp: 1789355443' \
--header 'X-Nonce: 7921b5d1-3b99-48d4-a068-ea7cf0c998db' \
--header 'X-Signature: 90cd7c99308406868fcdad059a9e61aef0a5d1275756885c4e277aa20e787a4b' \
--header 'Idempotency-Key: sms-doc-example-20260914-0001' \
--data-binary '{"mobile":"13800138000","content":"【示例签名】您的验证码是1234565分钟内有效。","clientMessageId":"doc-example-20260914-0001"}'
```
**真实的是 UUID 生成过程、摘要和签名计算结果,不是客户凭据或业务调用成功。** 固定示例可离线复算,但演示凭据无效,时间戳也会过期。本次未执行这条命令。使用真实凭据时,必须重新生成时间戳、nonce 和签名,不能只替换 Access Key;执行有效的发送命令可能真正创建短信任务并产生费用。
### 11.3 短信状态
```bash
curl --include --max-time 15 --request GET \
"https://api.lisglo.com/api/openapi/v1/sms/messages/MSG-22222222-2222-4222-8222-222222222222" \
--header "X-App-Key: $ACCESS_KEY" \
--header "X-Timestamp: $TIMESTAMP" \
--header "X-Nonce: $NONCE" \
--header "X-Signature: $SIGNATURE"
```
成功响应示例:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"clientMessageId": "order-20260914-0001",
"phoneNumber": "13800138000",
"status": "delivered",
"submitStatus": "accepted",
"receiptStatus": "delivered",
"errorCode": null,
"errorMessage": null,
"queuedAt": "2026-09-14T02:00:00.000Z",
"submittedAt": "2026-09-14T02:00:01.000Z",
"deliveredAt": "2026-09-14T02:00:03.000Z",
"updatedAt": "2026-09-14T02:00:03.100Z"
}
```
失败响应示例:
```http
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/message_not_found",
"title": "NOT_FOUND",
"status": 404,
"code": "MESSAGE_NOT_FOUND",
"detail": "短信记录不存在"
}
```
### 11.4 上行列表第一页
```bash
curl --include --max-time 15 --request GET \
"https://api.lisglo.com/api/openapi/v1/sms/uplinks" --get \
--data-urlencode "startTime=2026-09-13T16:00:00Z" \
--data-urlencode "endTime=2026-09-14T16:00:00Z" \
--data-urlencode "limit=25" \
--header "X-App-Key: $ACCESS_KEY" \
--header "X-Timestamp: $TIMESTAMP" \
--header "X-Nonce: $NONCE" \
--header "X-Signature: $SIGNATURE"
```
成功响应示例:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{
"id": "uplink-example-001",
"messageId": null,
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z"
}
],
"nextCursor": null
}
```
失败响应示例:
```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/time_range_too_large",
"title": "BAD_REQUEST",
"status": 400,
"code": "TIME_RANGE_TOO_LARGE",
"detail": "单次查询不能超过31天"
}
```
空列表响应:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [],
"nextCursor": null
}
```
### 11.5 上行下一页
第 11.4 节只有一条记录的示例已结束,所以 nextCursor 为 null,不应继续请求。以下表示**另一个有两条记录的分页场景,第一页和下一页均使用 limit=1**。第一页省略 cursor,其他时间条件与第 11.4 节相同。将下面 nextCursor 完整保存到 `$CURSOR`;这是由虚构记录生成的格式示例,线上必须用真实上一响应的值。
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{
"id": "uplink-example-001",
"messageId": null,
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z"
}
],
"nextCursor": "WyIyMDI2LTA5LTE0VDAyOjA1OjAwLjAwMFoiLCJ1cGxpbmstZXhhbXBsZS0wMDEiXQ"
}
```
保持第一页时间和筛选不变,生成新的请求唯一标识、时间戳和签名:
```bash
curl --include --max-time 15 --get \
"https://api.lisglo.com/api/openapi/v1/sms/uplinks" \
--data-urlencode "startTime=2026-09-13T16:00:00Z" \
--data-urlencode "endTime=2026-09-14T16:00:00Z" \
--data-urlencode "limit=1" \
--data-urlencode "cursor=$CURSOR" \
--header "X-App-Key: $ACCESS_KEY" \
--header "X-Timestamp: $TIMESTAMP" \
--header "X-Nonce: $NONCE" \
--header "X-Signature: $SIGNATURE"
```
下一页返回较早的第二条记录,nextCursor 为 null,表示没有更多页:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{
"id": "uplink-example-000",
"messageId": null,
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "好的",
"receivedAt": "2026-09-14T02:04:00.000Z"
}
],
"nextCursor": null
}
```
非法游标示例:
```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/cursor_invalid",
"title": "BAD_REQUEST",
"status": 400,
"code": "CURSOR_INVALID",
"detail": "cursor格式非法"
}
```
### 11.6 上行详情
```bash
curl --include --max-time 15 --request GET \
"https://api.lisglo.com/api/openapi/v1/sms/uplinks/uplink-example-001" \
--header "X-App-Key: $ACCESS_KEY" \
--header "X-Timestamp: $TIMESTAMP" \
--header "X-Nonce: $NONCE" \
--header "X-Signature: $SIGNATURE"
```
成功响应示例:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"id": "uplink-example-001",
"messageId": null,
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z",
"tenantId": "tenant-example",
"applicationId": "application-example"
}
```
失败响应示例:
```http
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/uplink_not_found",
"title": "NOT_FOUND",
"status": 404,
"code": "UPLINK_NOT_FOUND",
"detail": "上行记录不存在"
}
```
### 11.7 两类回调的完整交互
以下签名位置为说明占位符,必须用第 8.3 节规则计算,不是有效固定签名。平台投递 URL 是客户配置的地址。
**回执通知:**
```http
POST /webhooks/sms/receipt HTTP/1.1
Host: customer.example.com
Content-Type: application/json
X-Event-Id: evt_receipt_example001
X-Event-Type: receipt
X-Timestamp: 1789344000
X-Signature: sha256=<本次原始请求体的有效签名>
{
"eventId": "evt_receipt_example001",
"eventType": "receipt",
"occurredAt": "2026-09-14T02:00:03.100Z",
"data": {
"messageId": "MSG-22222222-2222-4222-8222-222222222222",
"gatewayMessageId": "example-gateway-id",
"phoneNumber": "13800138000",
"receiptStatus": "delivered",
"rawStatus": "DELIVRD",
"errorCode": null,
"deliveredAt": "2026-09-14T02:00:03.000Z"
}
}
```
**上行通知:**
```http
POST /webhooks/sms/uplink HTTP/1.1
Host: customer.example.com
Content-Type: application/json
X-Event-Id: evt_uplink_example001
X-Event-Type: uplink
X-Timestamp: 1789344000
X-Signature: sha256=<本次原始请求体的有效签名>
{
"eventId": "evt_uplink_example001",
"eventType": "uplink",
"occurredAt": "2026-09-14T02:05:00.100Z",
"data": {
"applicationId": "application-example",
"phoneNumber": "13800138000",
"destId": "106900000000",
"content": "收到,谢谢",
"receivedAt": "2026-09-14T02:05:00.000Z",
"uplinkMessageId": "uplink-example-001"
}
}
```
两类通知均在验签并可靠保存后确认:
```http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
OK
```
若接收端暂时无法可靠保存,可返回如下失败(正文由客户自定义,不是平台统一错误格式):
```http
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain; charset=utf-8
temporarily unavailable
```
预期会进入可重试分支,但当前自动重试缺陷仍未修复,不能仅凭返回503就假设平台一定补投。验签失败可返回401/403,这类响应通常不自动重试;应排查密钥、时间戳和原始字节。
### 11.8 公共鉴权失败
四个接口都可能在进入业务处理前返回以下响应:
```http
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://cmpp-platform.local/problems/signature_invalid",
"title": "UNAUTHORIZED",
"status": 401,
"code": "SIGNATURE_INVALID",
"detail": "请求签名校验失败"
}
```
其他公共失败见第 9 节。返回体的业务码和说明用于展示当前常见分支;真实网关错误可能不是该格式。
## 12. 版本与兼容说明
本次保持v1四个业务路径、五行LF签名、无体GET兼容摘要和请求唯一标识规则。clientMessageId必须为字符串或null、最长128字符;limit必须为正整数。上行列表与详情只输出公共业务字段,详情可含所属企业和应用ID,不再输出通道或内部匹配信息。
回调是至少一次投递,请按eventId去重。查询和回调的字段、类型以当前环境提供的OpenAPI JSON为准;接入前完成签名校对,并保存requestId和messageId以便排障。