短信 API 文档

从你自己的系统发短信到马来西亚手机号。线路、逐号码送达报告和价格都和控制台账户一样(标准每段 RM0.20, 量大自动适用更低的阶梯价)。代码示例里的注释保留英文,参数和返回字段以英文名为准。

基础网址 —— 所有接口都以它为前缀,必须使用 HTTPS:

https://hantarsms.com/api/v1

身份验证

在控制台的 API 密钥页签创建 API 密钥。密钥只在创建时显示一次。我们只保存它的哈希值,所以无法再显示给你 —— 弄丢的话,停用它再新建一把。

每个请求都要带上密钥,以下两种请求头都可以:

X-API-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

密钥只能放在服务器端。它可以花掉你的余额。绝对不要把它放进手机 App、网页,或任何会发给最终用户的东西里 —— 任何读到它的人都能用你的账户发短信,直到你停用它为止。 API 故意不发送 CORS 请求头,所以网页无法直接调用。

你可以同时持有多把密钥(例如每个环境一把),并可分别停用。停用即时生效。

幂等性

每次发送都带上 Idempotency-Key 请求头。它是可选的,但不带的话,一个在你这边超时的请求结果就真的不确定:短信可能已经建了、也可能没有,重试可能会扣你两次钱,并给每个收件人发两条短信。

Idempotency-Key: order-48213-reminder

用任何不超过 255 个字符、对这一次发送唯一的字符串 —— 订单号、UUID、任务 ID 都可以。带上这个请求头后,完全相同的请求重试多少次都可以:第一次真正处理,之后每一次都返回相同的响应,包括同一个短信 id。重放的响应会带 Idempotent-Replay: true 响应头。

情况会怎样
同一个 key,同样的请求体返回原来的响应。不会再发送,也不会再扣费。
同一个 key,不同的请求体422 idempotency_key_reuse。说明你生成 key 的逻辑有 bug —— 如果我们返回第一次的响应,你会以为第二条已经发了,其实根本没发。
同一个 key,第一次请求还在处理409 idempotency_key_in_progress。几秒后重试。
请求在建立任何东西之前就失败了
(参数错误、余额不足、内容被拦)
key 会被释放。修正问题后用同一个 key 重试。

key 以你的账户为范围,保留 24 小时。

发送短信

POST/api/v1/messages

参数类型说明
tostring 或 array必填。一个马来西亚手机号,或号码数组,每个请求最多 10000 个。接受 0123456789、60123456789 和 +60123456789,都会统一成 60XXXXXXXXX。重复号码会被去掉,只收一次费。
textstring必填。短信内容,最多 1600 个字符。
referencestring可选。你自己给这次发送的标签,最多 200 个字符。每次读取都会返回,也会显示在控制台。
send_atstring可选。预约发送而不是马上发:ISO 8601 时间戳,例如 2026-10-01T09:00:00+08:00。请带上时区;没有时区的时间戳按马来西亚时间(UTC+08:00)解读。 必须在现在起 2 分钟到 30 天之间;短信会在该时间后 5 分钟内发出。请求成功时即扣费。预约发送只能在网页控制台取消,不能通过 API 取消。 这个参数拼错(例如写成 scheduled_at)会以 invalid_parameter 拒绝,而不是直接马上发送;其他不认识的参数会被忽略,并列在 warnings 数组里。
curl https://hantarsms.com/api/v1/messages \
  -H "X-API-Key: $HANTARSMS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-48213-reminder" \
  -d '{
    "to": ["60123456789", "0198765432"],
    "text": "Your order #48213 is ready for pickup.",
    "reference": "order-48213"
  }'

201 Created:

{
  "id": 4821,
  "status": "queued",
  "scheduled_at": null,
  "reference": "order-48213",
  "text": "Your order #48213 is ready for pickup.",
  "encoding": "gsm",
  "segments": 1,
  "recipients": 2,
  "invalid_recipients": [],
  "rate_cent": 20,
  "cost_cent": 40,
  "balance_cent": 158360,
  "created_at": "2026-09-11T09:04:39.479Z"
}

金额一律以「仙」为单位、用整数表示。cost_cent: 40 就是 RM0.40。我们从不用小数表示令吉 —— 浮点数迟早会把 23.10 变成 23.099999999999998,对账就对不上了。显示时除以 100 即可。

请求成功的那一刻,按 segments × recipients × rate_cent 扣费。 rate_cent 是你账户当前的每段单价(升到更高的阶梯会自动降价),scheduled_at 是预约短信的 UTC 发送时间,非预约则为 null。 分段按运营商的方式计算:单段 160 个 GSM 字符,需要拼接时每段 153 个(只要含任何非 ASCII 字符就切换成 Unicode,变成 70 / 67)。 上游通道会在每条短信前加 4 个字符前缀,也算在额度里,所以一段可以放 156 个纯英文字符,Unicode 则是 66 个字。

不是有效马来西亚手机号的号码会被剔除,列在 invalid_recipients(前 20 个),不收费。如果一个有效号码都没有,会返回 422 no_valid_recipients。

查询一条短信

GET/api/v1/messages/{id}

轮询这个接口来跟进一次发送。调用时会从运营商刷新送达计数(每条短信最多每 30 秒刷新一次;轮询更频繁时返回最后已知状态,而不是报错)。

curl https://hantarsms.com/api/v1/messages/4821 \
  -H "X-API-Key: $HANTARSMS_KEY"
{
  "id": 4821,
  "status": "done",
  "reference": "order-48213",
  "text": "Your order #48213 is ready for pickup.",
  "encoding": "gsm",
  "segments": 1,
  "recipients": 2,
  "submitted": 2,
  "delivered": 1,
  "failed": 1,
  "rate_cent": 20,
  "cost_cent": 40,
  "refunded_cent": 0,
  "error": null,
  "scheduled_at": null,
  "created_at": "2026-09-11 09:04:39"
}

cost_cent 是建立短信时扣的金额。refunded_cent 是之后因为无法提交(rejected)的号码退回的金额。这条短信实际花了你多少,就是 cost_cent − refunded_cent。列表接口里的短信也带同样的字段。

时间一律是 UTC。发送接口返回的 created_at 是 ISO 8601 格式(2026-09-11T09:04:39.479Z)。查询接口返回的 created_at 和 submitted_at 是 YYYY-MM-DD HH:MM:SS、不带时区标记 —— 那也是 UTC,不是马来西亚时间。要换成马来西亚时间请加 8 小时,或者按 UTC 解析(例如解析前在后面加上 Z)。 scheduled_at 一律是 ISO 8601 UTC。

逐号码送达结果

GET/api/v1/messages/{id}/recipients

每个号码的结果,而不只是总数。支持 limit(默认 100,最多 1000)和 offset。 没有以 delivered 结束的号码,error 里会有一句英文说明;把它当作记录或显示用的文字,判断逻辑请看 status。

curl "https://hantarsms.com/api/v1/messages/4821/recipients?limit=2" \
  -H "X-API-Key: $HANTARSMS_KEY"
{
  "message_id": 4821,
  "data": [
    { "to": "60123456789", "status": "delivered",   "error": null, "submitted_at": "2026-09-11 09:04:41" },
    { "to": "60198765432", "status": "undelivered", "error": "Carrier could not deliver (number off, unreachable, invalid or blocked).", "submitted_at": "2026-09-11 09:04:41" }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0,
  "has_more": false
}

短信列表

GET/api/v1/messages

最新的在前。支持 limit(默认 20,最多 100)和 offset。

{
  "data": [ { "id": 4821, "status": "done", … } ],
  "total": 137,
  "limit": 20,
  "offset": 0,
  "has_more": true
}

查询余额

GET/api/v1/balance

{
  "balance_cent": 158360,
  "rate_cent": 20,
  "currency": "MYR",
  "segments_remaining": 7918
}

rate_cent 是你账户当前的每段单价。调用成本很低,可以在大批量发送前查一下,或定时查询、在额度快用完前提醒自己。

状态值

短信 status

值含义
queued已接收并扣费,还没交给运营商。
sending正在提交。大批量会分块发出,所以这个状态下计数会持续上升。
done所有收件人都处理完了。结果看 delivered / failed。
failed一个都没能提交。没发出去的号码已扣的费用会自动退回。

收件人 status

值含义是否收费
pending还没提交给运营商。暂扣
sent运营商已接收,还没有最终回执。收费
delivered运营商确认已送到手机。有些运营商从不回传回执;运营商已接收、30 分钟内(发给超过 10 个号码时为 60 分钟)既没有回执也没有拒收的号码,同样算作 delivered,其 error 字段会注明。收费
undelivered运营商已接收,但没送到手机 —— 关机、号码已停用、被拦截。收费
rejected我们根本没能提交。不收费 —— 退回

rejected 和 undelivered 是故意分开的。运营商按提交收费,不是按送达,所以被接收后没能送到关机手机的短信照样收费。 rejected 的号码从没被提交,会退款。把两者合并成一个「failed」值,正是让 API 账单无法对账的原因。

还有一种情况:我们提交时上游报错或连接中断,结果未知,短信可能已经发出。这个号码照收不退、也不会重试,状态报告为 undelivered。

错误

所有错误都返回同样的结构。请根据 code 判断 —— 它是稳定的约定。message 是给人看的,随时可能改写。

{
  "error": {
    "type": "insufficient_funds",
    "code": "insufficient_balance",
    "message": "This request costs 100000 cent (RM1000.00) but the account balance is 99940 cent (RM999.40).",
    "required_cent": 100000,
    "balance_cent": 99940
  }
}
HTTPcode该怎么做
400invalid_json请求体不是 JSON 对象。
400missing_parameter缺少 to。看 param。
400invalid_parametersend_at 不是有效时间戳,或不在现在起 2 分钟 – 30 天之内,或 Idempotency-Key 超过 255 个字符。
401missing_api_key没有带密钥请求头。
401invalid_api_key密钥不存在。
401revoked_api_key密钥已停用。新建一把。
402insufficient_balance充值后用同一个幂等 key 重试。
403account_disabled联系客服。
404not_found你的账户里没有这个 id 的短信。
405method_not_allowed这个路径用错了 HTTP 方法。
409idempotency_key_in_progress几秒后重试。
413request_too_large请求体超过 1 MB。每个请求最多 10000 个收件人。
422no_valid_recipients没有一个是有效的马来西亚手机号。
422too_many_recipients超过 10000 个。分批发送。
422message_empty / message_too_long修改 text。
422content_blocked见下方。
422idempotency_key_reuse同一个 key、不同请求体。修正你生成 key 的逻辑。
429rate_limit_exceeded放慢速度;遵守 Retry-After。
500internal_error我们这边的问题。可以安全地用同一个幂等 key 重试。

被拦截的内容

马来西亚电信对含受保护国际品牌名的短信每段罚 RM1.50 —— 是短信本身价格的七倍多 —— 并向发送方追讨。平台可能会拦截或标记这类短信;被拦截时你会收到这个错误,不扣费:

{
  "error": {
    "type": "content_blocked",
    "code": "content_blocked",
    "message": "Message contains protected international brand keywords (1xBet). …",
    "param": "text",
    "brands": ["1xBet"],
    "max_penalty_cent": 150
  }
}

去掉品牌名后重发。品牌名检测不保证每次都抓得到,所以请自己避免在短信里写受保护的品牌名 —— 漏网的短信照样会被电信罚款,并转嫁给你。

频率限制

接口上限
POST /messages默认每把密钥每分钟 60 个请求;验证码流量可以调高(见下方)
所有 GET 接口每把密钥每分钟 300 个请求
验证失败每个 IP 地址每 5 分钟 20 次

前两个限制按 API 密钥计算,不按 IP,所以你自己的不同流量不会互相抢额度。429 会带 Retry-After 请求头(单位秒)。 每个请求最多可以带 10000 个收件人,所以发送上限远高于任何正常的批量需求 —— 如果碰到上限,请在每个请求里多放号码,而不是发得更快。 验证码这种「一个请求一个号码」的流量没办法合并;如果你的高峰每分钟超过 60 个验证码,请通过控制台里的 WhatsApp 链接联系我们,我们会为你的账户调高上限。

验证失败的限制按 IP 计算,而且不区分是哪把密钥失败。只能这样:密钥认不出来时,我们根本不知道是谁在调用,只剩地址可以计数。 同一个地址连续失败 20 次后,在这个时间窗口内,该地址的所有请求都会被拒绝 —— 包括带着有效密钥的请求。

实际上只会在部署出错时碰到:错误或已停用的密钥在紧密的重试循环里,几秒就用完 20 次。请把 401 当作致命错误而不是可重试的错误 —— 修好密钥再试,或者等 Retry-After 过去。

发送验证码(OTP)

注册、登录、重置密码的验证码都用同一个 POST /messages 接口。验证码由你自己的系统生成和核对,我们只负责发短信(价格和送达速度见短信验证码)。这类流量有几件事影响很大:

curl https://hantarsms.com/api/v1/messages \
  -H "X-API-Key: $HANTARSMS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-7f3a91" \
  -d '{"to":"60123456789","text":"Your MyShop code is 482913. Valid for 5 minutes. Do not share it.","reference":"otp signup"}'

Java 客户端:设置 User-Agent

User-Agent 以 Java/1. 开头的请求 —— 也就是 Java 8 及更早版本 java.net.HttpURLConnection 的默认值 —— 会在网络边缘被拒绝,返回 HTTP 403 和一段 HTML,根本到不了 API。这是全平台的机器人特征规则,不是针对你的账户。

设置任何一个你自己的 User-Agent 就没事了:

HttpURLConnection c = (HttpURLConnection) url.openConnection();
c.setRequestProperty("User-Agent", "my-app/1.0");

Apache HttpClient、OkHttp、Java 9+,以及我们测试过的所有非 Java 客户端(PHP/Guzzle、python-requests、Node、Go、curl)都不受影响。不管怎样,发送一个有描述性的 User-Agent 都是好习惯 —— 你请我们追查某个请求时,我们第一个看的就是它。

完整示例

$ch = curl_init('https://hantarsms.com/api/v1/messages');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_TIMEOUT        => 30,
  CURLOPT_HTTPHEADER => [
    'X-API-Key: ' . getenv('HANTARSMS_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: order-' . $orderId,
  ],
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => json_encode([
    'to'        => ['60123456789'],
    'text'      => "Your order #$orderId is ready.",
    'reference' => "order-$orderId",
  ]),
]);
$res  = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($code === 201) {
  echo "Queued as message {$res['id']}\n";
} elseif ($res['error']['code'] === 'insufficient_balance') {
  // top up, then retry with the SAME Idempotency-Key
} else {
  error_log("SMS failed: {$res['error']['code']}");
}

开始使用

注册账户,WhatsApp 找我们领 RM1 试用额度,然后在控制台的 API 密钥页签生成密钥。试用额度在 API 上和网页控制台完全一样可用,所以你可以在写任何对接代码之前,先发一条真实短信到自己手机。 用 PHP、Laravel 或 WordPress 的话,验证码教程里有可以直接贴上的完整代码。

打开控制台 查看价格