向印度用户发送商业短信时,API返回“发送成功”并不意味着短信已经到达用户手机。在印度DLT(分布式账本技术)体系下,短信从提交到最终送达需要经过DLT清洗、运营商路由、终端投递等多个环节,每个环节都可能出现失败。**获取真实的发送状态,必须依赖短信API的回调机制。**
本文将详细解析印度短信API的回调(Webhook)机制,对比Webhook与轮询两种状态获取方式的优劣,并提供Webhook接收端的代码实现和DLT特有的状态码解读。
短信发送流程的复杂性决定了状态获取的必要性。以印度市场为例,一条商业短信的完整路径是:
> 你的应用 → 短信API → **DLT清洗(Scrubbing)** → 电信运营商 → 接收方手机
DLT清洗环节是印度特有的“过滤网”——运营商根据TRAI规定,逐字符比对短信内容与备案模板、验证Sender ID和Entity ID的合法性。清洗失败的消息会被永久拒绝,且不会重试。同时,促销类短信的DLR(Delivery Report)已被运营商停止发送,只能标记为“已提交至运营商”。这些DLT特有的状态,只有通过回调机制才能准确获取。
获取短信发送状态有两种主流方式:**Webhook回调**和**API轮询**。
| 对比维度 | Webhook回调 | API轮询 |
|---|---|---|
| 实时性 | 状态变化后秒级推送 | 取决于轮询频率,通常延迟数分钟 |
| 服务器压力 | 低,仅在状态变化时接收请求 | 高,需持续发起查询请求 |
| 实现复杂度 | 需搭建可公网访问的接收端点 | 仅需调用查询接口 |
| 可扩展性 | 适合大规模发送场景 | 高频轮询易触发速率限制 |
| DLR可用性 | 交易类/服务类短信可获取 | 同样受运营商DLR政策影响 |
**Webhook是生产环境的首选方案**。SMSGatewayCenter的官方文档明确指出,Webhook让状态“在几秒内到达”,而轮询主要用于Webhook丢失后的回填和审计。对于OTP和交易类短信,Webhook的实时性直接影响重试决策——如果第一条短信因DLT清洗失败,你需要立即知道并触发降级通道(如语音OTP)。
Webhook回调的本质是**服务端主动推送**。短信服务商在状态变化时,向你在控制台配置的URL发起POST请求,请求体中包含消息ID和状态信息。
**典型的状态变化触发场景**:
- 消息已提交至运营商(submitted)
- 消息已送达(delivered)
- 消息失败(failed)
- 消息被DLT清洗拒绝(DLT scrubbing failure)
**第一步:创建Webhook端点**
在短信服务商控制台或通过API注册Webhook URL。SMSGatewayCenter提供专门的Webhook创建接口:
```
POST https://www.lanlansms.com/sms/
```
配置时需指定:Webhook URL、每秒事务数(TPS)限制、认证方式。
**第二步:确保Webhook端点可公网访问**
Webhook URL必须能从服务商的服务器访问,支持POST方法,且不设置IP白名单拦截。建议使用HTTPS协议,避免中间人攻击。
**第三步:验证Webhook连通性**
使用RequestBin或webhook.site等工具生成临时URL,将其配置为Webhook端点,发送测试短信后检查是否收到POST请求。
**第四步:在发送请求中传递reportURL(可选)**
部分服务商支持在单次发送请求中动态指定DLR回调地址。SMSGatewayCenter的发送API支持`reportURL`参数:
```
reportURL=https://www.lanlansms.com/sms/
```
这种方式适合多租户场景,不同客户可以使用不同的回调端点。
以下以Python Flask为例,展示一个生产级的Webhook接收端。
```python
from flask import Flask, request, jsonify
import logging
import hmac
import hashlib
app = Flask(__name__)
logger = logging.getLogger(__name__)
# 配置:DLR状态映射
DLR_STATUS_MAP = {
"1": "DELIVERED",
"2": "FAILED",
"3": "PENDING",
"4": "EXPIRED",
"5": "REJECTED",
"6": "DLT_SCRUBBING_FAILED",
"7": "SUBMITTED_TO_OPERATOR"
}
@app.route('/sms/dlr', methods=['POST'])
def handle_dlr():
"""接收短信DLR回调"""
try:
# 1. 验证请求来源(推荐)
# 实际生产环境应验证服务商签名或IP白名单
payload = request.get_json(force=True)
message_id = payload.get('messageId') or payload.get('message_id')
status_code = str(payload.get('status') or payload.get('statusCode'))
mobile = payload.get('mobile')
operator = payload.get('operator')
timestamp = payload.get('timestamp')
# 2. 解析DLR状态
status = DLR_STATUS_MAP.get(status_code, "UNKNOWN")
# 3. 记录日志(用于审计和排查)
logger.info(
f"DLR received: msgId={message_id}, mobile={mobile}, "
f"status={status}, operator={operator}, ts={timestamp}"
)
# 4. 更新业务数据库中的发送状态
# update_sms_status(message_id, status, operator)
# 5. 根据状态触发后续动作
if status == "DLT_SCRUBBING_FAILED":
# DLT清洗失败——模板不匹配或Sender ID未注册
# 触发告警,检查DLT模板配置
logger.error(f"DLT scrubbing failed for msgId={message_id}")
# alert_dlt_config_issue(message_id)
elif status == "FAILED" and operator:
# 运营商级失败——可能需要降级到语音通道
# trigger_voice_fallback(mobile)
pass
elif status == "DELIVERED":
# 成功送达——更新统计
pass
# 6. 必须返回200状态码,否则服务商会持续重试
return jsonify({"status": "ok"}), 200
except Exception as e:
logger.exception(f"DLR handling error: {e}")
# 即使处理失败,也返回200避免重复推送
return jsonify({"status": "error"}), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
```
**关键实现要点**:
- **返回200状态码**:无论处理是否成功,必须返回HTTP 200。否则服务商会认为回调失败并持续重试,造成重复处理
- **幂等处理**:同一消息ID可能收到多次回调(如状态从submitted变为delivered),需用消息ID+状态做去重
- **DLT特定状态处理**:`DLT_SCRUBBING_FAILED`表示模板与备案不匹配或Sender ID未注册,需立即触发告警而非重试
- **日志留存**:所有回调请求需完整记录,以备RBI审计和运营商投诉处理
印度DLT体系下的短信状态码与普通市场有显著差异。以下为常见DLT相关状态:
| 状态码 | 含义 | 是否可重试 | 处理建议 |
|---|---|---|---|
| DELIVERED | 已送达用户手机 | — | 正常完成 |
| SUBMITTED_TO_OPERATOR | 已提交至运营商(促销类短信的最终状态) | — | 运营商不再返回DLR,视为完成 |
| DLT_SCRUBBING_FAILED | DLT清洗失败 | 不可重试 | 检查模板、Sender ID、Entity ID是否匹配 |
| FAILED_DND | 因DND拦截失败 | 促销类不可重试 | 促销短信不能发送至DND号码,改用Service Implicit模板 |
| INVALID_TEMPLATE | 模板ID无效或未注册 | 不可重试 | 确认模板已通过DLT审核 |
| INVALID_ENTITY | Entity ID无效 | 不可重试 | 确认Entity ID在DLT平台有效 |
| INVALID_SENDER | Sender ID未注册或格式错误 | 不可重试 | 确认6位Sender ID已关联到正确模板 |
**DLT清洗失败的处理逻辑**:当收到`DLT_SCRUBBING_FAILED`状态时,**不要重试同一条消息**——重试同样会被拦截。正确的做法是:检查发送时的`msg`内容是否与备案模板逐字符一致,确认`dltTemplateId`和`dltEntityId`是否正确传递,修正后重新发送。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 从未收到回调 | Webhook URL未配置或配置错误 | 在控制台确认Webhook URL已保存 |
| 回调偶尔丢失 | Webhook端点超时或返回非200 | 检查服务端响应时间,确保<5秒返回200 |
| 重复收到相同回调 | 未做幂等处理,服务商重试 | 用消息ID+状态做去重键 |
| 回调中DLT状态缺失 | 促销类短信运营商不返回DLR | 促销类短信只能依赖submitted状态 |
| Webhook被防火墙拦截 | 服务商IP未在白名单中 | 获取服务商出口IP并加入白名单 |
印度短信API回调机制的核心要点可以概括为 **“Webhook为主、轮询为辅、DLT状态优先处理”** :
1. **Webhook是首选方案**:实时性高、服务器压力低,适合OTP和交易类短信的即时状态获取
2. **正确配置Webhook端点**:确保公网可访问、返回200、支持POST
3. **DLT状态码必须识别**:`DLT_SCRUBBING_FAILED`不可重试,需立即排查模板和Sender ID配置
4. **幂等处理是底线**:同一消息ID可能收到多次状态更新,去重逻辑不可省略
5. **轮询作为回填机制**:Webhook丢失时用`getDlr`接口回填状态,确保状态完整性
对于向印度发送短信的开发团队而言,**Webhook接收端的健壮性直接决定了消息状态的可追踪性**。建议从第一天就建立完整的DLR处理管道——包括幂等去重、DLT状态识别、告警触发和审计日志——而不是等到生产环境出问题后才补上。