开头先说个结论用Python调SUBMAIL这类短信服务商的API发国际短信本质上就是构造一次带签名的HTTP请求难点从来不在“发”这个动作上而在于你对签名算法、号码格式、内容编码和回执跟踪的理解是否到位。我见过不少项目代码写得没毛病最后却在“收不到短信”上折腾了好几天原因往往只是手机号少了个加号或者超长内容被按多条计费但没有做拆分。这篇文章把我实际接入时的完整流程过一遍从为什么选SUBMAIL这类聚合服务商到appid/appkey怎么拿再到签名怎么算、代码怎么写、坑怎么排。如果你是第一次接触短信API跟着做就能跑通如果你已经接过了国内短信重点看第三节的签名逻辑和第四节的排障清单能帮你省不少时间。1. 先理清国际短信的发送链路和基本选择1.1 国际短信和国内短信到底差在哪国内短信走的是三大运营商自家的网间走廊号码实名制、通道规范都相对统一你只要解决“发出去”的问题就行。但国际短信完全不是这个概念短信要从你选择的服务商服务器出发经过国际关口局、目的国家的运营商网络最后落到对方手机上。不同国家的运营商策略、内容过滤规则、号码段格式都不一样甚至同一个国家内不同运营商的到达率也会有明显差别。这也是为什么很多出海项目不会选择自己去对接运营商。个人开发者或者中小团队如果直接去找海外运营商谈通道流程长、门槛高、起充费用不低而且你得自己维护一堆协议和故障排查工作。退一步讲即便你有能力对接一条短信的最终送达还要依赖目标国家运营商的配合这不是靠写代码能解决的问题。所以最务实的路径是找一家已经帮你把通道、计费、路由、协议都打点好的短信聚合服务商通过标准的HTTP API把内容交出去然后等服务商回传状态。SUBMAIL就是这类服务商里比较典型的一个它提供REST风格的接口文档齐全还支持国际短信发送。用这类接口你可以把短信能力当成一个普通的云服务来用出问题也能找到明确的责任边界。1.2 为什么建议用API而不是找“能发短信”的杂牌通道我知道有人会说与其折腾API不如直接找一些看起来便宜的小渠道充值然后让他们后台帮忙发。这种路子短平快但有几个隐患。第一你无法把发送能力嵌入自己的业务系统。用户注册验证码、订单状态通知、告警消息这些都是程序自动触发的场景人工后台操作根本无法规模化。第二杂牌通道的到达率没有保障。国际短信的很多通道是按地区、按运营商分开的有些小渠道名义上“能发全球”实际上可能只覆盖部分国家或者走了高延迟的劣质路由最终结果是用户收不到、你也查不到原因。第三合规性不明确。国际短信涉及目的国家的垃圾短信治理法规如果服务商没有在当地做好合规登记发出去的短信可能直接被运营商拦截。SUBMAIL这类服务商会把接口权限、内容审核、发送记录、回执回调都做成标准功能。你把发送请求交给它它能回传一条短信的实时状态比如已提交、已送达、被拒绝或者失效。这些状态对应的是你业务里的“用户有没有收到验证码”没有这层数据排查问题全靠猜那是灾难。1.3 理解国际短信的计费方式避免预算失控国际短信的计费跟国内不太一样它是按“目的地国家”和“内容条数”双维度算钱的。不同国家的单价差异很大有些国家一条短信只要几分钱有些国家一条可能要几毛甚至更贵。更需要注意的是超长短信问题一条短信的容量通常是160个英文字符或70个中文字符超出之后会被拆分成多条发送计费也就按多条算。所以在写代码的时候我强烈建议你在发送前做一次内容长度预估或者在界面层就限制输入长度。否则用户填了一段200字的消息你以为发了一条后台账单却记了三条月底对账时完全对不上。这个细节我会在后面实操部分展开。2. 准备工作账号、密钥、发送者标识一个都不能少2.1 注册服务商并创建应用拿到appid和appkeySUBMAIL这类平台的使用逻辑很统一你先注册账号然后创建一个应用系统会给你两个关键凭证——appid和appkey。appid相当于应用的身份证号每次请求都要带上appkey则是你生成签名的密钥绝对不能泄露它类似于你发短信的“口令”。创建应用时一般会让你选签名类型或加密方式。常见的选项有无签名、MD5签名、SHA1签名等。我建议优先选择SHA1安全性比MD5更稳妥而且主流服务商都支持。这个选择会直接影响后面代码里签名的算法不要选完就忘了否则对照文档写代码时会懵。拿到凭证之后第一件事不是写代码而是先测试发送一条短信给自己。SUBMAIL一般会提供测试用的短信余额你把自己的手机号填入“测试号码”列表然后按照平台自带的调试工具发一条。这一步能确认你的账号本身是通的也能提前发现手机号格式、Sender ID配置之类的基础问题。2.2 配置Sender ID决定用户手机上显示什么国际短信有一个国内短信不太会遇到的设置叫Sender ID也就是收件人手机上看到的发送者标识。它可以是一个字母数字组合的别名比如VKJ、STORE也可以是一串号码。很多国家对Sender ID有审核要求未审核的Sender ID在发送时可能会被替换成随机号码导致用户看到一串陌生数字信任感大打折扣。我建议在正式发送之前先到服务商后台把Sender ID提交审核。通知类短信通常用品牌缩写营销类短信可能还需要额外申请。内容模棱两可的话可以先提交一个和公司名或产品名一致的英文缩写审核通过率会高很多。2.3 密钥管理不要把appkey写死在代码里这是老生常谈但我还是要强调appkey一旦提交到Git仓库里就等于把发短信的权限公开了。任何拿到appkey的人都能用你的账号发短信导致费用一夜之间被刷爆。正确的做法是通过环境变量注入比如在运行服务器的环境里配置export SUBMAIL_APPID你的appid export SUBMAIL_APPKEY你的appkey然后在Python代码里读取import os APP_ID os.getenv(SUBMAIL_APPID) APP_KEY os.getenv(SUBMAIL_APPKEY)这样代码本身不包含敏感信息即使代码泄露密钥也不会跟着泄露。如果你用的是容器部署还可以结合密钥管理服务去挂载那是更高阶的做法但至少先把环境变量这条底线守住。3. Python调用SUBMAIL SMS API签名、发送、状态解析一步到位3.1 接口地址和基础参数长什么样SUBMAIL的国际短信接口通常通过message/send这个端点来发送对应的完整地址类似https://api.submail.cn/message/send.json请求方式是POST参数以表单形式提交。基础参数至少包含这几个参数名说明appid应用ID公开信息to接收号码需要带国际区号content短信内容signature签名根据appkey生成timestamp当前时间戳防重放攻击注意to字段如果你要发给中国大陆的手机号应该写成8613812345678发给美国号码写成14155552671也就是“”加国家代码加去掉首位0的号码。很多第一次接入的人把号码写成13812345678不带区号服务商会认为这是一个无效的全球号码直接拒绝。3.2 签名算法拆解这是整个接入的核心签名的作用是让服务商确认请求确实来自你而且参数在传输过程中没有被篡改。SUBMAIL这类服务商的通用签名逻辑是把除签名本身以外的所有请求参数按参数名的字典序排序然后拼接成keyvaluekey2value2形式的字符串再与appkey做哈希运算。我的实际做法是这样的import hashlib import time import requests def build_signature(params: dict, appkey: str) - str: # 过滤掉空值和签名本身 filtered {k: v for k, v in params.items() if k ! signature and v not in (None, )} # 按key的字典序排序并拼接 sorted_pairs [f{k}{filtered[k]} for k in sorted(filtered.keys())] raw_string .join(sorted_pairs) # 拼接appkey后做SHA1 raw_string f{raw_string}key{appkey} return hashlib.sha1(raw_string.encode(utf-8)).hexdigest()这里有几个细节需要特别注意第一排序用的是Python内置的sorted它会按字符的ASCII顺序排列服务端也是这么做的只要两边一致就不会出问题。第二拼接时key和value之间是等号参数之间是符号这个格式不能乱。第三参与签名的参数要和实际请求的参数完全一致包括你最终传给requests.post的所有字段。如果你在构造签名时漏掉了某个参数或者签名字段顺序和后端校验不一致服务端肯定会返回验签失败。我之所以说“这是整个接入的核心”是因为很多人在参数排序和拼接上栽跟头。比如有人会把appid排在content前面因为看起来“好看”但服务端是按字典序排的两边不一致签名必然对不上。3.3 完整发送函数从拼接参数到捕获响应下面是完整的发送函数我习惯返回服务端的JSON响应对应字典方便上层业务直接判断import hashlib import time import requests def build_signature(params: dict, appkey: str) - str: filtered {k: v for k, v in params.items() if k ! signature and v not in (None, )} sorted_pairs [f{k}{filtered[k]} for k in sorted(filtered.keys())] raw_string .join(sorted_pairs) raw_string f{raw_string}key{appkey} return hashlib.sha1(raw_string.encode(utf-8)).hexdigest() def send_international_sms(appid: str, appkey: str, to: str, content: str) - dict: params { appid: appid, to: to, content: content, timestamp: str(int(time.time())), } params[signature] build_signature(params, appkey) try: resp requests.post( https://api.submail.cn/message/send.json, dataparams, timeout15, ) result resp.json() except requests.exceptions.RequestException as e: # 网络层异常要做记录方便后续重试 return {status: network_error, msg: str(e)} return result调用方式很简单result send_international_sms( appidAPP_ID, appkeyAPP_KEY, to8613812345678, content[你的产品名] Your verification code is 123456., ) print(result)如果一切正常返回的JSON会包含一个类似msgid的消息ID以及status字段值为success或者failure。你需要把msgid存到日志里后面查回执、对账、排查问题都要靠它。关于超时设置我建议至少给到10到15秒。国际短信接口涉及服务商到海外通道的转发响应速度不一定像国内接口那么快超时设得太短容易误判为发送失败。3.4 不要混淆“发送成功”和“用户收到”这是新手最容易误解的一点。接口返回success只代表SUBMAIL接受了你的发送请求不代表短信已经到达用户手机。真正的送达确认需要靠回调通知或者主动查询发送状态。SUBMAIL一般会提供一个查询接口你用msgid去查询能得到这条短信的最终状态比如delivered表示已送达failed表示未送达expired表示通道超时。如果是验证码这类时效性要求高的场景你不能只在发送时看一眼接口响应就完事最好通过回调接收状态变更把“已送达”这个信号和业务逻辑对齐。4. 国际短信的常见坑与排查思路4.1 手机号格式问题少一个加号短信就发不出去我遇到过的最高频问题就是号码格式不对。用户提交的手机号可能是13812345678也可能是8613812345678甚至可能是86 138 1234 5678这种带空格的格式。如果直接把这些字符串传给接口很大概率会被拒绝。建议在发送前做一次标准化处理import re def normalize_phone(phone: str) - str: phone phone.replace( , ).replace(-, ) if phone.startswith(00): phone phone[2:] if not phone.startswith(): phone phone return phone这个函数把常见的空格、短横线去掉再把00开头的国际拨号格式转成开头。做完这层清洗再传给发送函数能减少大量因格式问题导致的发送失败。请注意这个处理只适用于“带国际区号的完整号码”如果你在业务里单独存了国家代码和本地号码拼接时也要记得用来拼接。4.2 内容编码和超长短信拆分的坑国际短信的正文默认按UTF-8编码中文字符没问题但内容长度和计费关系密切。一条标准短信的容量大约是160个英文字符或70个中文字符超过这个长度就会被拆分成多条发送计费也按多条计算。如果你的业务允许用户输入较长的内容必须在发送前做长度预估。一个实用方法def estimate_sms_segments(content: str, encoding: str auto) - int: if encoding auto: # 如果包含中文按UCS2编码估算 has_cjk any(\u4e00 ch \u9fff for ch in content) chars_per_segment 70 if has_cjk else 160 else: chars_per_segment 70 if encoding ucs2 else 160 return max(1, -(-len(content) // chars_per_segment))这段代码用了向上取整的写法可以快速算出一条短信会拆成几条。如果你的业务对成本敏感建议在UI层就限制输入长度并显示“约计费X条”的提示避免用户引起误解也避免月底账单吓人一跳。还有一个隐蔽的坑有些特殊字符比如Emoji、数学符号、带组合标记的字符在短信里会被转成更复杂的编码可能占用多个字符位。如果你的内容面向海外用户最好在发送前对Emoji做一次过滤或替换。不是所有通道都支持Emoji即使支持也极大概率触发超长拆分。4.3 发送者标识和模板审核国际短信也有反垃圾要求只是执行方式跟国内不同。你配置的Sender ID如果不够规范比如包含明显广告词或者内容里携带短链接且没有备案都会被目的国家运营商或服务商拦截。我建议这样处理Sender ID用品牌缩写不要用一串乱码通知类短信内容尽量固定模板比如验证码模板、订单通知模板营销类短信预留退订指引很多国家的法规要求营销短信必须包含退订方式否则会被投诉并影响通道质量。如果你发现同一批号码发送后到达率明显偏低优先检查是不是内容触发了目标国家的过滤规则。这种问题不是代码能解决的需要调整文案或者改用已审核过的模板。4.4 到达率不稳定的排查顺序假设接口返回success短信也显示已提交但用户就是收不到这种情况怎么排查我的习惯是按下述顺序来查先查手机号是不是欠费或者关机这个属于用户侧问题没法控制。查服务商后台的发送记录看msgid对应的状态码是什么。如果是delivered但用户说没收到可能是用户手机把短信放进了垃圾箱。查Sender ID是否审核通过。没有审核通过的Sender ID可能被替换成共享号码用户看到陌生号码会直接忽略。查发送时间。很多国家有夜间静默或运营商限流时段半夜发送的营销短信到达率通常比白天低。抽样测试。换自己的手机号以及目的国家当地手机号分别做小规模测试对比结果。这个排查顺序能帮你快速定位问题出在“发件侧”还是“收件侧”避免盲目重发。4.5 回执回调让系统知道“短信到底到没到”回执回调是短信接入里很重要但容易被忽略的一环。SUBMAIL支持在后台配置回调URL短信状态变化时会向这个URL发送POST请求。你的服务端收到后需要根据msgid找到对应的业务流水更新订单状态或者触发补偿逻辑。我用Flask写过一个简单的回调接收接口大致长这样from flask import Flask, request app Flask(__name__) app.route(/sms_callback, methods[POST]) def sms_callback(): data request.get_json() or request.form.to_dict() msgid data.get(msgid) event data.get(event) # 在这里根据msgid更新业务表状态 return ok, 200回调接口务必返回200否则服务商可能会视为接收失败并重复推送。另外建议对回调做幂等处理因为服务商可能多次推送同一条消息你不能让用户状态被覆盖成旧值。4.6 测试环境与生产环境隔离短信是真实计费的而且发送后几乎没有撤回机制所以在代码联调阶段必须在“白名单模式”下运行。SUBMAIL后台一般支持设置测试号码列表只有列表里的号码才会真正触发发送其余号码只能看到模拟成功结果。我在接入期间的习惯是测试环境单独用一个appid测试号码限定为寥寥几个正式发送走另一个appid再配合环境变量切换。这样即使测试代码误触发也不会影响真实用户更不会产生额外费用。5. 几个值得长期保留的实操习惯最后一个主题聊点我自己的经验习惯。这些东西在官方文档里不会集中写但实际运营时特别管用。第一永远把msgid当成核心线索。发送成功、查询状态、回调匹配、跟服务商客服沟通都靠这个ID。建议在业务数据库里单独建一张短信记录表字段至少包括外部业务ID、msgid、手机号、内容概要、发送时间、状态、回调时间。这张表就是你的短信黑匣子以后排查问题全都从这张表下手。第二前置做内容安全过滤。国际短信的内容审核不仅发生在服务商那边也可能会被目的国家运营商二次拦截。与其等发送失败再补救不如在业务层先做一些基础检查比如敏感词过滤、链接白名单、Emoji过滤。这些规则可以由运营人员维护但代码层面一定要预留接口不然每一次内容策略调整都要发版。第三灰度测试别省。即使你已经在沙箱环境测通了正式上线阶段也建议先用真实的小流量跑一两天观察送达率和回执延迟。国际短信的通道质量不是恒定的某些国家的运营商经常调整策略灰度能让你在问题扩大化之前发现异常。比如我接触过的一个项目某个国家工作日白天送达率一直正常变更发送时间到半夜后送达率骤降到20%以下这种问题只有真实流量才能暴露出来。第四对“发送成功”保持怀疑。所有接口都有状态返回但只有“已送达”才是你想要的结果。系统里应该把发送状态拆成四级提交成功、发送中、已送达、失败。每一次用户反馈“没收到验证码”都可以直接查这条短信卡在哪个阶段而不是简单地重新发一条。我个人在实际调试中还有一个笨办法但非常有效把发送内容和签名原始串在测试阶段完整打印到日志里。一旦验签失败手机号格式错误或者内容被拦截日志会直接告诉你问题在哪一行。等一切稳定了再关掉详细日志只保留必要信息。这个习惯帮我排掉了大量看着“没问题但就是发不过去”的诡异问题。