1166 lines
49 KiB
Markdown
1166 lines
49 KiB
Markdown
# 客户 HTTP 短信接口接入手册
|
||
|
||
**接口版本:v1 · 手册修订:2026-09-14**
|
||
|
||
适用对象:需要从自己的业务系统发送短信、查询短信结果或接收用户回复的开发人员。
|
||
|
||
本文说明 HTTP 接口的接入步骤、鉴权、请求与响应以及回调处理。所有号码、消息编号和响应示例均为说明用途,不是真实客户数据。GET 空请求体使用第 3 节规定的兼容摘要;每次请求都须重新生成请求唯一标识和签名。
|
||
|
||
建议先完成上行查询与签名校对,再接入发送和回调。每个接口的参数说明后均附有对应示例;复制示例不会执行请求。
|
||
|
||
## 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`;兼容原 8~128 字符规则 |
|
||
| `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 计算请求签名
|
||
|
||
签名分为三个步骤:计算请求体摘要、拼接五行原文、计算 HMAC。HTTP 请求头中的时间戳和 nonce 必须与签名时使用的值完全一致。
|
||
|
||
**第一步:计算请求体摘要。** POST 使用最终发送的 JSON 原始字节计算 SHA256;不要在签名后修改空格、字段顺序或中文转义。无请求体的 GET 使用第 3.3 节规定的兼容摘要。
|
||
|
||
**第二步:按以下顺序拼接五行。** 路径包含 /api,不含域名或查询参数;如路径参数经过 URL 编码,使用实际发送的编码后路径。
|
||
|
||
五行签名原文:
|
||
|
||
```text
|
||
大写 HTTP 方法
|
||
请求路径
|
||
X-Timestamp 的原始字符串
|
||
X-Nonce 的原始字符串
|
||
请求体的 SHA256 十六进制摘要
|
||
```
|
||
|
||
相邻两行用一个 LF 换行符(0x0A)连接,最后一行后不加换行。不要使用 Windows CRLF,也不要加入引号或多余空格。URL 查询参数使用的 & 不是签名分隔符,query 不参与本版本签名。
|
||
|
||
**第三步:计算 HMAC-SHA256。** 使用 Secret 的 UTF-8 字节作为密钥,五行原文的 UTF-8 字节作为输入,输出 64 位十六进制字符串,放入 X-Signature。
|
||
|
||
签名计算公式:
|
||
|
||
```text
|
||
SIGNING_STRING = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH
|
||
SIGNATURE = HEX(HMAC_SHA256(UTF8(Secret), UTF8(SIGNING_STRING)))
|
||
```
|
||
|
||
公式中的 \n 表示一个 LF 字节,不是反斜杠和字母 n 两个字符。Secret 直接使用,不做 Base64 解码。示例输出纯十六进制签名;服务端也接受 sha256= 前缀,但无须添加。Idempotency-Key 是业务幂等头,不属于上述五行,也不能代替 nonce。
|
||
|
||
### 3.3 当前 GET 空请求体的兼容规则
|
||
|
||
**当前版本的特殊行为:不带请求体的 GET,摘要需要按 UTF-8 字符串 `{}` 计算。GET 本身仍不发送请求体。**
|
||
|
||
固定摘要为:
|
||
|
||
```text
|
||
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
|
||
```
|
||
|
||
请勿使用空字节的 SHA256 代替这个摘要。本版本为兼容已有接入保留此规则;GET 请求仍不携带 body。
|
||
|
||
### 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 和自己的凭据。
|
||
|
||
### 3.6 公共准备与占位符
|
||
|
||
本节每个接口均提供 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 传入,避免重新序列化;本稿没有自动执行发送的入口。
|
||
|
||
|
||
## 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 响应示例。
|
||
|
||
除鉴权头外,需要 `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 换键反复发送。修正确定失败的业务后是否发起新发送,由你的业务流程明确决定。
|
||
|
||
### 5.4 单条发送
|
||
|
||
将第 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对应的请求内容不一致"
|
||
}
|
||
```
|
||
|
||
#### 5.4.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 语法执行。脚本用于演示,会打印鉴权头;正式接入时凭据从安全配置读取,不把完整鉴权信息写入共享日志。
|
||
|
||
#### 5.4.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":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}'
|
||
```
|
||
|
||
**真实的是 UUID 生成过程、摘要和签名计算结果,不是客户凭据或业务调用成功。** 固定示例可离线复算,但演示凭据无效,时间戳也会过期。本次未执行这条命令。使用真实凭据时,必须重新生成时间戳、nonce 和签名,不能只替换 Access Key;执行有效的发送命令可能真正创建短信任务并产生费用。
|
||
|
||
|
||
## 6. 查询短信状态
|
||
|
||
完整 cURL 请求和 HTTP 响应见本节后附示例。
|
||
|
||
`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、unknown;null 表示尚无结果 |
|
||
| `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 也不能单独证明业务永远不会落库。
|
||
|
||
### 6.1 短信状态
|
||
|
||
```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": "短信记录不存在"
|
||
}
|
||
```
|
||
|
||
|
||
## 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 列表与筛选
|
||
|
||
第一页、空数据及下一页的完整报文见第 7.1、7.2 节。
|
||
|
||
`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.1.1 上行列表第一页
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
|
||
### 7.2 翻页方法
|
||
|
||
1. 第一页固定 startTime、endTime 和筛选条件,不传 cursor。
|
||
2. 若 nextCursor 非 null,下一次保持时间、筛选和 limit 不变,只加入 cursor;仍需新的 timestamp/nonce/签名。
|
||
3. nextCursor 为 null 时结束。没有总条数、页码或 totalPages。
|
||
|
||
记录按接收时间、记录 id 倒序返回。cursor 是不透明令牌,不自行解码、构造或当作长期同步水位。上行可能延迟到达或稍后才被认领;持续同步宜重叠查询时间窗口并按 id 去重,不把一次分页当作永不变化的快照。
|
||
|
||
### 7.2.1 上行下一页
|
||
|
||
第 7.1.1 节只有一条记录的示例已结束,所以 nextCursor 为 null,不应继续请求。以下表示**另一个有两条记录的分页场景,第一页和下一页均使用 limit=1**。第一页省略 cursor,其他时间条件与第 7.1.1 节相同。将下面 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格式非法"
|
||
}
|
||
```
|
||
|
||
|
||
### 7.3 上行详情
|
||
|
||
完整 cURL 请求和 HTTP 响应见第 7.3.1 节。
|
||
|
||
`GET /api/openapi/v1/sms/uplinks/{uplinkId}`,无请求体。
|
||
|
||
把列表的 `items[].id` 或回调的 `data.uplinkMessageId` 放入路径,按第 3 节签名。返回单个上行对象,核心字段与列表中的 item 相同,不再包裹 items。
|
||
|
||
详情额外返回当前客户自己的 `tenantId`、`applicationId`。不返回通道、供应商编号、内部事件/匹配诊断、数据库关联行号等字段。本次按数据边界收紧旧输出,旧客户需移除对这些内部字段的依赖。不存在或不属于当前企业应用时返回 404 `UPLINK_NOT_FOUND`。
|
||
|
||
### 7.3.1 上行详情
|
||
|
||
```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": "上行记录不存在"
|
||
}
|
||
```
|
||
|
||
|
||
## 8. 平台怎样主动通知你的系统
|
||
|
||
### 8.1 配置与接收
|
||
|
||
在客户端“回调配置”分别设置:
|
||
|
||
- **回执回调 URL**:接收 `receipt`,用于获知短信结果。
|
||
- **上行回调 URL**:接收 `uplink`,用于接收用户回复。
|
||
|
||
平台向对应 URL 发起 HTTP POST。需开通该类回调能力、保存非空有效地址。使用平台可访问的公网 HTTPS 地址,不使用本机、内网或依赖重定向的地址。每个回调端点使用自己的签名密钥。
|
||
|
||
不要假设每次发送请求都会有回调。同步参数错误、未受理的请求以及未生成该类事件的分支,应处理接口响应或查询结果。长短信的 HTTP 结果通知按业务消息处理,不按运营商内部计费分片逐片回调。
|
||
|
||
### 8.2 请求头和事件格式
|
||
|
||
两类通知的完整 HTTP 请求及接收端响应见第 8.5 节。
|
||
|
||
| 回调头 | 说明 |
|
||
| --- | --- |
|
||
| `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 秒,不应依赖它作为严格端到端时限。
|
||
|
||
平台会保存回调待办,并按重试规则处理暂时失败。网络中断可能造成同一事件被重复接收,请始终按 eventId 去重。对于长期未收到的历史事件,请先查询投递记录并联系平台核对,不要将其视为短信发送失败。
|
||
|
||
### 8.5 两类回调的完整交互
|
||
|
||
以下签名位置为说明占位符,必须用第 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,这类响应通常不自动重试;应排查密钥、时间戳和原始字节。
|
||
|
||
|
||
## 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 或完整鉴权头。
|
||
|
||
### 9.1 公共鉴权失败
|
||
|
||
四个接口都可能在进入业务处理前返回以下响应:
|
||
|
||
```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 节。返回体的业务码和说明用于展示当前常见分支;真实网关错误可能不是该格式。
|
||
|
||
|
||
## 10. 当前版本的接入边界
|
||
|
||
| 事项 | 当前说明 |
|
||
| --- | --- |
|
||
| 接口定义 | 当前环境的 OpenAPI JSON 提供请求参数与响应结构,可用于生成客户端;本文补充接入步骤和业务含义 |
|
||
| GET 空体 | 当前须使用 `{}` 摘要;后续变更需确认兼容策略 |
|
||
| 回调重试 | 暂时失败按第 8 节规则重试,接收端按 eventId 去重 |
|
||
| 超时与幂等恢复 | 受理快照与消息/待办同事务;结果无法确定时返回 REQUEST_REQUIRES_REVIEW,不能换幂等键绕过 |
|
||
| 参数与响应模型 | clientMessageId为string/null且最长128;limit为正整数,按应用上限裁剪;上行不返回内部通道和匹配字段 |
|
||
| 性能 | 以应用配置限制请求频率;QPS 配置不是对全链路送达能力的承诺 |
|
||
| 示例用途 | 示例使用演示数据;实际接入需使用本应用凭据并重新生成时间戳、nonce 和签名 |
|
||
|
||
## 11. 版本与兼容说明
|
||
|
||
本次保持v1四个业务路径、五行LF签名、无体GET兼容摘要和请求唯一标识规则。clientMessageId必须为字符串或null、最长128字符;limit必须为正整数。上行列表与详情只输出公共业务字段,详情可含所属企业和应用ID,不再输出通道或内部匹配信息。
|
||
|
||
回调是至少一次投递,请按eventId去重。查询和回调的字段、类型以当前环境提供的OpenAPI JSON为准;接入前完成签名校对,并保存requestId和messageId以便排障。
|