# 客户 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`;兼容原 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 签名原文 按下面的固定顺序拼接字符串;`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、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 也不能单独证明业务永远不会落库。 ## 7. 查询手机用户回复(上行) ### 7.0 先分清四种时间边界 | 项目 | 当前规则 | 举例 | | --- | --- | --- | | 默认查询范围 | endTime 默认现在;startTime 默认 endTime 前 24 小时 | 什么时间都不传,就是最近 24 小时 | | 单次最大查询跨度 | 默认 31 天,以应用配置为准;限制起止时间之差 | 可查询三个月前某一天,并不是只能查询最近 31 天 | | 历史数据保留期 | 有默认 90 天的配置,但尚未确认存在按该配置执行的保留/清理闭环 | 不能承诺一定有 90 天数据,也不能断言更早数据必定被删除 | | 游标有效期 | 当前游标没有单独过期计时;不保证数据长期不变 | 不将旧 cursor 当作永久同步凭据,重新固定时间窗口并去重 | 查询代码没有额外强制“只查最近 N 天”,但历史查询能否返回数据取决于实际留存、当前应用归属和筛选条件。startTime 必须不晚于 endTime,当前时间边界两端均包含。跨窗口同步可按上行 id 去重,避免公共边界重复计入。 ### 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` | 检查时间格式、先后顺序和跨度 | | 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且最长128;limit为正整数,按应用上限裁剪;上行不返回内部通道和匹配字段 | | 性能 | 以应用配置限制请求频率;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":"【示例签名】您的验证码是123456,5分钟内有效。","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以便排障。